feat: réécriture frontend Alpine.js + HTMX + Swup (branche frontend/alpine)

Remplace Vue 3 / Vite / TypeScript par une stack légère statique :
- Alpine.js v3 : réactivité inline, stores auth/ui/i18n, composants par page
- HTMX v2 : interactions serveur via attributs HTML
- Swup v4 : transitions de page (bundlé via esbuild, IIFE browser-loadable)
- xterm.js v5 : terminal PTY (bundlé via esbuild)

Structure : HTML statiques + js/app.js + js/terminal.js + css/ + locales/
Build : esbuild (bundle Swup + xterm seulement) → dist/ → Nginx
Dockerfile simplifié : node:22-alpine build → nginx:1.27-alpine serve

Pages : index, install, login, dashboard, proxmox, updates, terminal, settings, modules
URLs propres via nginx try_files $uri.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 16:19:24 +01:00
parent 7ba0ff143c
commit 2098c80ec1
48 changed files with 2446 additions and 5317 deletions

2
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -1,26 +1,21 @@
# ── Étape 1 : Build du frontend Vue 3 + Vite ───────────────────────────────
# ── Étape 1 : Build (bundle Swup + xterm via esbuild) ─────────────────────
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
COPY package.json package-lock.json ./
RUN npm ci
# Copier le code source et compiler
COPY . .
RUN npm run build
# ── Étape 2 : Image Nginx pour servir le frontend ──────────────────────────
# ── Étape 2 : Nginx pour servir les fichiers statiques ─────────────────────
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
EXPOSE 80

92
frontend/build.mjs Normal file
View file

@ -0,0 +1,92 @@
/**
* Build script for ProxmoxPanel Alpine frontend.
* - Bundles Swup into an IIFE (browser-loadable)
* - Bundles xterm.js + addon-fit into IIFEs
* - Copies all static assets to dist/
*/
import * as esbuild from 'esbuild'
import * as fs from 'fs'
import * as path from 'path'
const dist = 'dist'
// Clean dist
fs.rmSync(dist, { recursive: true, force: true })
fs.mkdirSync(`${dist}/js/vendors`, { recursive: true })
fs.mkdirSync(`${dist}/js`, { recursive: true })
fs.mkdirSync(`${dist}/css`, { recursive: true })
fs.mkdirSync(`${dist}/locales`, { recursive: true })
// 1. Bundle Swup into IIFE
console.log('Bundling Swup...')
await esbuild.build({
entryPoints: ['swup-bundle.entry.mjs'],
bundle: true,
format: 'iife',
globalName: '_swupExports',
outfile: `${dist}/js/vendors/swup.iife.js`,
minify: true,
})
// Expose Swup on window
const swupOut = fs.readFileSync(`${dist}/js/vendors/swup.iife.js`, 'utf8')
fs.writeFileSync(`${dist}/js/vendors/swup.iife.js`,
swupOut + '\nwindow.Swup=_swupExports.Swup;')
// 2. Bundle xterm.js
console.log('Bundling xterm...')
await esbuild.build({
entryPoints: ['xterm-bundle.entry.mjs'],
bundle: true,
format: 'iife',
globalName: '_xtermExports',
outfile: `${dist}/js/vendors/xterm.iife.js`,
minify: true,
})
const xtermOut = fs.readFileSync(`${dist}/js/vendors/xterm.iife.js`, 'utf8')
fs.writeFileSync(`${dist}/js/vendors/xterm.iife.js`,
xtermOut + '\nwindow.Terminal=_xtermExports.Terminal;window.FitAddon=_xtermExports.FitAddon;')
// xterm CSS
const xtermCss = 'node_modules/@xterm/xterm/css/xterm.css'
if (fs.existsSync(xtermCss)) {
fs.copyFileSync(xtermCss, `${dist}/css/xterm.css`)
}
// 3. Copy pre-downloaded vendors (Alpine, HTMX)
for (const f of ['alpine.min.js', 'htmx.min.js']) {
const src = `vendors/${f}`
if (fs.existsSync(src)) {
fs.copyFileSync(src, `${dist}/js/vendors/${f}`)
console.log(`Copied ${f}`)
} else {
console.warn(`WARN: ${src} not found`)
}
}
// 4. Copy app JS files
for (const f of fs.readdirSync('js')) {
if (f.endsWith('.js')) {
fs.mkdirSync(`${dist}/js`, { recursive: true })
fs.copyFileSync(`js/${f}`, `${dist}/js/${f}`)
}
}
// 5. Copy CSS
for (const f of fs.readdirSync('css')) {
fs.copyFileSync(`css/${f}`, `${dist}/css/${f}`)
}
// 6. Copy locales
for (const f of fs.readdirSync('locales')) {
fs.copyFileSync(`locales/${f}`, `${dist}/locales/${f}`)
}
// 7. Copy HTML pages
for (const f of fs.readdirSync('.')) {
if (f.endsWith('.html')) {
fs.copyFileSync(f, `${dist}/${f}`)
}
}
console.log('✓ Build complete → dist/')

View file

@ -370,3 +370,202 @@
--sidebar-width: 100%;
}
}
/* ── Layout Alpine (sidebar + navbar + page-content) ───────────────────────── */
:root {
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--navbar-height: 56px;
}
body {
display: flex;
min-height: 100vh;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.2s ease;
flex-shrink: 0;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
overflow: hidden;
}
.sidebar.collapsed {
width: var(--sidebar-collapsed-width);
}
.sidebar-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
min-height: var(--navbar-height);
}
.sidebar-logo {
font-size: 1.5rem;
color: var(--accent-primary);
flex-shrink: 0;
}
.sidebar-title {
font-weight: 700;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
}
.sidebar-toggle {
margin-left: auto;
flex-shrink: 0;
}
.sidebar-nav {
flex: 1;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
text-decoration: none;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
}
.sidebar-link:hover {
background: var(--bg-hover, rgba(255,255,255,0.05));
color: var(--text-primary);
}
.sidebar-link.active {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-primary);
}
.sidebar-icon {
font-size: 1.1rem;
flex-shrink: 0;
width: 1.25rem;
text-align: center;
}
.sidebar-label {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 0.5rem;
}
.sidebar-user {
flex: 1;
font-size: 0.8rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Main layout */
.main-layout {
display: flex;
flex-direction: column;
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
transition: margin-left 0.2s ease;
overflow: hidden;
}
.sidebar.collapsed ~ .main-layout,
body:has(.sidebar.collapsed) .main-layout {
margin-left: var(--sidebar-collapsed-width);
}
/* Navbar */
.navbar {
height: var(--navbar-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 50;
}
.navbar-title {
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.navbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Page content */
.page-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
height: calc(100vh - var(--navbar-height));
}
/* Swup fade transition */
.transition-fade {
transition: opacity 0.2s ease;
}
html.is-animating .transition-fade {
opacity: 0;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: var(--sidebar-collapsed-width);
}
.main-layout {
margin-left: var(--sidebar-collapsed-width);
}
}
[x-cloak] { display: none !important; }

158
frontend/dashboard.html Normal file
View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Dashboard</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data>
<!-- Sidebar -->
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<span class="sidebar-logo"></span>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle" @click="toggle()"></button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link"
:class="{ active: isActive(item.id) }"
:href="item.href"
@click.prevent="navigate(item.href)">
<span class="sidebar-icon" x-text="item.icon"></span>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer" x-show="!collapsed">
<span class="sidebar-user" x-text="$store.auth.user?.username || ''"></span>
<button class="neu-btn neu-btn--sm" @click="$store.auth.logout()"></button>
</div>
</aside>
<div class="main-layout">
<!-- Navbar -->
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.dashboard')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()"
:title="theme === 'dark' ? t('navbar.lightMode') : t('navbar.darkMode')">
<span x-text="theme === 'dark' ? '☀' : '🌙'"></span>
</button>
</div>
</nav>
<!-- Contenu swappé -->
<main id="swup" class="page-content transition-fade" x-data="dashboardPage()" x-cloak>
<!-- Stats -->
<div class="stats-grid">
<div class="neu-card stat-card">
<div class="stat-icon"></div>
<div class="stat-value" x-text="runningCount"></div>
<div class="stat-label">En cours</div>
</div>
<div class="neu-card stat-card">
<div class="stat-icon"></div>
<div class="stat-value" x-text="stoppedCount"></div>
<div class="stat-label">Arrêtés</div>
</div>
<div class="neu-card stat-card">
<div class="stat-icon"></div>
<div class="stat-value" x-text="lxcList.length"></div>
<div class="stat-label">LXC</div>
</div>
<div class="neu-card stat-card">
<div class="stat-icon"></div>
<div class="stat-value" x-text="vmList.length"></div>
<div class="stat-label">VM</div>
</div>
</div>
<!-- Status WS -->
<div class="ws-status" :class="wsStatus">
<span x-show="wsStatus === 'connecting'">⌛ Connexion…</span>
<span x-show="wsStatus === 'ok'">● Live</span>
<span x-show="wsStatus === 'disconnected'">⚠ Déconnecté (reconnexion…)</span>
<span x-show="wsStatus === 'error'">✗ Erreur WebSocket</span>
</div>
<!-- LXC List -->
<div class="section">
<h3 class="section-title">Containers LXC</h3>
<div class="resource-grid" x-show="lxcList.length > 0">
<template x-for="r in lxcList" :key="r.vmid">
<div class="neu-card resource-card" :class="r.status">
<div class="resource-header">
<span class="resource-name" x-text="r.name || 'LXC ' + r.vmid"></span>
<span class="resource-id" x-text="'#' + r.vmid"></span>
<span class="resource-badge" :class="r.status" x-text="r.status"></span>
</div>
<div class="resource-metrics" x-show="r.status === 'running'">
<div class="metric">
<span class="metric-label">CPU</span>
<div class="metric-bar">
<div class="metric-fill" :style="'width:' + Math.round((r.cpu||0)*100) + '%;background:' + cpuColor(Math.round((r.cpu||0)*100))"></div>
</div>
<span class="metric-val" x-text="Math.round((r.cpu||0)*100) + '%'"></span>
</div>
<div class="metric">
<span class="metric-label">RAM</span>
<div class="metric-bar">
<div class="metric-fill" :style="'width:' + Math.round((r.mem||0)/(r.maxmem||1)*100) + '%'"></div>
</div>
<span class="metric-val" x-text="formatMem(r.mem)"></span>
</div>
</div>
</div>
</template>
</div>
<p class="empty-state" x-show="lxcList.length === 0 && wsStatus === 'ok'">Aucun container</p>
<div class="loading" x-show="wsStatus === 'connecting'">Chargement…</div>
</div>
</main>
</div>
<style>
[x-cloak]{display:none!important}
.main-layout{display:flex;flex-direction:column;flex:1;margin-left:var(--sidebar-width,240px);transition:margin-left .2s}
.sidebar.collapsed~.main-layout{margin-left:var(--sidebar-collapsed-width,64px)}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-card{padding:1.25rem;text-align:center}
.stat-icon{font-size:1.5rem;margin-bottom:.5rem;color:var(--accent-primary)}
.stat-value{font-size:2rem;font-weight:700}
.stat-label{font-size:.8rem;color:var(--text-secondary)}
.ws-status{padding:.5rem 1rem;border-radius:.5rem;font-size:.8rem;margin-bottom:1rem;background:var(--bg-secondary)}
.ws-status.ok{color:var(--color-success,#22c55e)}
.ws-status.disconnected,.ws-status.error{color:var(--color-warning,#f59e0b)}
.resource-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.resource-card{padding:1rem}
.resource-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}
.resource-name{font-weight:600;flex:1}
.resource-id{font-size:.75rem;color:var(--text-secondary)}
.resource-badge{font-size:.7rem;padding:.2rem .5rem;border-radius:.25rem;text-transform:uppercase}
.resource-badge.running{background:rgba(34,197,94,.15);color:var(--color-success,#22c55e)}
.resource-badge.stopped{background:rgba(239,68,68,.1);color:var(--color-error,#ef4444)}
.metric{display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem}
.metric-label{font-size:.7rem;color:var(--text-secondary);min-width:30px}
.metric-bar{flex:1;height:6px;border-radius:3px;background:var(--bg-secondary);overflow:hidden}
.metric-fill{height:100%;border-radius:3px;background:var(--accent-primary);transition:width .5s}
.metric-val{font-size:.7rem;min-width:36px;text-align:right}
.section{margin-bottom:2rem}
.section-title{font-size:1rem;font-weight:600;margin-bottom:.75rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.05em}
.empty-state{color:var(--text-muted);font-size:.875rem}
.loading{color:var(--text-muted);font-size:.875rem}
</style>
</body>
</html>

View file

@ -1,15 +1,18 @@
<!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>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel</title>
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body>
<!-- La logique de redirection est dans app.js (DOMContentLoaded) -->
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:sans-serif;color:#94a3b8">
Chargement…
</div>
</body>
</html>

175
frontend/install.html Normal file
View file

@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Installation</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data>
<div class="auth-layout" x-data="installPage()" x-cloak>
<div class="install-card neu-card">
<div class="auth-logo">
<span class="logo-icon"></span>
<h1 class="auth-title">ProxmoxPanel</h1>
<p class="auth-subtitle" x-text="t('install.subtitle')"></p>
</div>
<!-- Stepper -->
<div class="stepper">
<template x-for="i in totalSteps" :key="i">
<div class="step" :class="{ active: step === i, done: step > i }">
<div class="step-dot" x-text="step > i ? '✓' : i"></div>
<div class="step-label" x-text="t('install.step' + i + '.label')"></div>
</div>
</template>
</div>
<!-- Step 1: Général -->
<div x-show="step === 1" class="step-content">
<h2 x-text="t('install.step1.title')"></h2>
<p class="step-desc" x-text="t('install.step1.desc')"></p>
<div class="form-group">
<label class="form-label" x-text="t('install.instanceName')"></label>
<input class="neu-input" type="text" x-model="form.instance_name"
:placeholder="t('install.instanceNamePlaceholder')" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.publicUrl')"></label>
<input class="neu-input" type="url" x-model="form.public_url" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.defaultLang')"></label>
<select class="neu-input" x-model="form.default_lang">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
<!-- Step 2: SSH -->
<div x-show="step === 2" class="step-content">
<h2 x-text="t('install.step2.title')"></h2>
<p class="step-desc" x-text="t('install.step2.desc')"></p>
<div class="form-group">
<label class="form-label" x-text="t('install.sshHost')"></label>
<input class="neu-input" type="text" x-model="form.ssh_host"
placeholder="10.0.0.1:22" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshUsername')"></label>
<input class="neu-input" type="text" x-model="form.ssh_username"
placeholder="enzo" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshPassword')"></label>
<input class="neu-input" type="password" x-model="form.ssh_password" />
</div>
<button class="neu-btn" type="button" @click="testSSH" :disabled="sshTesting">
<span x-show="!sshTesting" x-text="t('install.testSSH')"></span>
<span x-show="sshTesting">Test en cours…</span>
</button>
<div x-show="sshStatus === 'ok'" class="status-ok" x-text="t('install.sshSuccess')"></div>
<div x-show="sshStatus === 'error'" class="status-error" x-text="t('install.sshFailed')"></div>
</div>
<!-- Step 3: Proxmox API -->
<div x-show="step === 3" class="step-content">
<h2 x-text="t('install.step3.title')"></h2>
<p class="step-desc" x-text="t('install.step3.desc')"></p>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxUrl')"></label>
<input class="neu-input" type="url" x-model="form.proxmox_url"
placeholder="https://proxmox.example.com:8006" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxTokenId')"></label>
<input class="neu-input" type="text" x-model="form.proxmox_token_id"
placeholder="enzo@pam!panel" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxTokenSecret')"></label>
<input class="neu-input" type="text" x-model="form.proxmox_token_secret"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<p class="form-hint" x-text="t('install.proxmoxTokenHint')"></p>
</div>
<!-- Step 4: Confirmation -->
<div x-show="step === 4" class="step-content">
<h2 x-text="t('install.step4.title')"></h2>
<div class="confirm-summary neu-inset">
<div class="confirm-row">
<span class="confirm-label">Instance</span>
<span x-text="form.instance_name"></span>
</div>
<div class="confirm-row">
<span class="confirm-label">URL</span>
<span x-text="form.public_url"></span>
</div>
<div class="confirm-row">
<span class="confirm-label">SSH</span>
<span x-text="form.ssh_username + '@' + form.ssh_host"></span>
</div>
<div class="confirm-row" x-show="form.proxmox_url">
<span class="confirm-label">Proxmox</span>
<span x-text="form.proxmox_url"></span>
</div>
</div>
<div x-show="error" class="status-error" x-text="error"></div>
</div>
<!-- Navigation -->
<div class="step-nav">
<button class="neu-btn" type="button" @click="prevStep"
x-show="step > 1" :disabled="loading">
<span x-text="t('install.back')"></span>
</button>
<button class="neu-btn neu-btn--primary" type="button"
@click="step < totalSteps ? nextStep() : finish()"
:disabled="loading">
<span x-show="!loading && step < totalSteps" x-text="t('install.next')"></span>
<span x-show="!loading && step === totalSteps" x-text="t('install.finish')"></span>
<span x-show="loading" class="spinner"></span>
</button>
</div>
</div>
</div>
<style>
.auth-layout { min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem; }
.install-card { width:100%;max-width:560px;padding:2rem; }
.auth-logo { text-align:center;margin-bottom:1.5rem; }
.logo-icon { font-size:2.5rem;color:var(--accent-primary); }
.auth-title { font-size:1.5rem;font-weight:700;margin:.25rem 0; }
.auth-subtitle { font-size:.875rem;color:var(--text-secondary);margin:0; }
.stepper { display:flex;gap:.5rem;justify-content:center;margin:1.5rem 0; }
.step { display:flex;flex-direction:column;align-items:center;gap:.25rem;flex:1;max-width:100px; }
.step-dot { width:2rem;height:2rem;border-radius:50%;border:2px solid var(--border-color);display:flex;align-items:center;justify-content:center;font-size:.8rem;font-weight:700;transition:all .2s; }
.step.active .step-dot { border-color:var(--accent-primary);background:var(--accent-primary);color:#fff; }
.step.done .step-dot { border-color:var(--color-success,#22c55e);background:var(--color-success,#22c55e);color:#fff; }
.step-label { font-size:.7rem;color:var(--text-secondary);text-align:center; }
.step-content { display:flex;flex-direction:column;gap:1rem;min-height:200px; }
.step-content h2 { margin:0;font-size:1.125rem; }
.step-desc { margin:0;font-size:.875rem;color:var(--text-secondary); }
.form-group { display:flex;flex-direction:column;gap:.4rem; }
.form-label { font-size:.8rem;font-weight:600;color:var(--text-secondary); }
.form-hint { font-size:.75rem;color:var(--text-muted);margin:0; }
.step-nav { display:flex;justify-content:flex-end;gap:.75rem;margin-top:1.5rem; }
.status-ok { padding:.5rem .75rem;border-radius:.375rem;background:rgba(34,197,94,.1);border:1px solid var(--color-success,#22c55e);color:var(--color-success,#22c55e);font-size:.875rem; }
.status-error { padding:.5rem .75rem;border-radius:.375rem;background:rgba(239,68,68,.1);border:1px solid var(--color-error,#ef4444);color:var(--color-error,#ef4444);font-size:.875rem; }
.confirm-summary { padding:1rem;display:flex;flex-direction:column;gap:.5rem;border-radius:.5rem; }
.confirm-row { display:flex;gap:1rem;font-size:.875rem; }
.confirm-label { font-weight:600;min-width:80px;color:var(--text-secondary); }
.spinner { display:inline-block;width:1rem;height:1rem;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin .6s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
[x-cloak] { display:none!important; }
</style>
</body>
</html>

710
frontend/js/app.js Normal file
View file

@ -0,0 +1,710 @@
/**
* ProxmoxPanel Alpine.js stores + composants + Swup init + HTMX config
*
* Chargé AVANT alpine.min.js (qui est defer).
* L'événement 'alpine:init' est déclenché par Alpine avant qu'il parcourt le DOM.
*/
// ── Utilitaires ────────────────────────────────────────────────────────────
function apiFetch(path, opts = {}) {
const token = localStorage.getItem('pxp_token')
return fetch(path, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...(opts.headers || {}),
},
})
}
// ── Alpine:init ────────────────────────────────────────────────────────────
document.addEventListener('alpine:init', () => {
// ── Store auth ─────────────────────────────────────────────────────────
Alpine.store('auth', {
token: null,
user: null,
get isAuthenticated() { return !!this.token && !!this.user },
async init() {
this.token = localStorage.getItem('pxp_token')
if (this.token) {
try {
await this.fetchMe()
} catch {
await this.tryRefresh()
}
}
},
async fetchMe() {
const res = await apiFetch('/api/auth/me')
if (res.ok) {
this.user = await res.json()
} else if (res.status === 401) {
await this.tryRefresh()
}
},
async tryRefresh() {
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
this.token = data.token
localStorage.setItem('pxp_token', data.token)
await this.fetchMe()
} else {
this.clear()
}
},
async login(username, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Identifiants invalides')
}
const data = await res.json()
this.token = data.token
this.user = data.user
localStorage.setItem('pxp_token', data.token)
},
async logout() {
await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
this.clear()
window.location.href = '/login.html'
},
clear() {
this.token = null
this.user = null
localStorage.removeItem('pxp_token')
},
})
// ── Store UI ────────────────────────────────────────────────────────────
Alpine.store('ui', {
theme: localStorage.getItem('pxp_theme') || 'dark',
sidebarCollapsed: localStorage.getItem('pxp_sidebar') === 'true',
currentPage: '',
init() {
this.applyTheme()
// Detect current page from URL
const path = window.location.pathname
this.currentPage = path.replace(/^\/|\.html$/g, '') || 'index'
},
applyTheme() {
document.documentElement.setAttribute('data-theme', this.theme)
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark'
localStorage.setItem('pxp_theme', this.theme)
this.applyTheme()
},
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed
localStorage.setItem('pxp_sidebar', this.sidebarCollapsed)
},
})
// ── Store i18n ──────────────────────────────────────────────────────────
Alpine.store('i18n', {
lang: localStorage.getItem('pxp_lang') || 'fr',
msgs: {},
loaded: false,
async init() {
await this.load(this.lang)
},
async load(lang) {
try {
const res = await fetch(`/locales/${lang}.json`)
if (res.ok) {
this.msgs = await res.json()
this.lang = lang
localStorage.setItem('pxp_lang', lang)
this.loaded = true
}
} catch (e) {
console.error('i18n load error', e)
}
},
t(key, vars = {}) {
const parts = key.split('.')
let val = this.msgs
for (const p of parts) {
val = val?.[p]
if (val === undefined) return key
}
if (typeof val !== 'string') return key
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
},
async setLang(lang) {
await this.load(lang)
},
})
// ── Composant: sidebar ──────────────────────────────────────────────────
Alpine.data('sidebar', () => ({
get collapsed() { return Alpine.store('ui').sidebarCollapsed },
get currentPage() { return Alpine.store('ui').currentPage },
navItems: [
{ id: 'dashboard', icon: '⊞', labelKey: 'nav.dashboard', href: '/dashboard.html' },
{ id: 'proxmox', icon: '⬡', labelKey: 'nav.proxmox', href: '/proxmox.html' },
{ id: 'updates', icon: '↑', labelKey: 'nav.updates', href: '/updates.html' },
{ id: 'terminal', icon: '', labelKey: 'nav.terminal', href: '/terminal.html' },
{ id: 'settings', icon: '⚙', labelKey: 'nav.settings', href: '/settings.html' },
{ id: 'modules', icon: '⬡', labelKey: 'nav.modules', href: '/modules.html' },
],
isActive(id) {
return this.currentPage === id
},
navigate(href) {
if (window.swup) {
window.swup.navigate(href)
} else {
window.location.href = href
}
},
t(key) { return Alpine.store('i18n').t(key) },
toggle() { Alpine.store('ui').toggleSidebar() },
}))
// ── Composant: navbar ───────────────────────────────────────────────────
Alpine.data('navbar', () => ({
get theme() { return Alpine.store('ui').theme },
get user() { return Alpine.store('auth').user },
get lang() { return Alpine.store('i18n').lang },
toggleTheme() { Alpine.store('ui').toggleTheme() },
logout() { Alpine.store('auth').logout() },
async setLang(lang) { await Alpine.store('i18n').setLang(lang) },
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: loginPage ────────────────────────────────────────────────
Alpine.data('loginPage', () => ({
username: '',
password: '',
error: '',
loading: false,
async submit() {
this.error = ''
this.loading = true
try {
await Alpine.store('auth').login(this.username, this.password)
window.location.href = '/dashboard.html'
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: installPage ──────────────────────────────────────────────
Alpine.data('installPage', () => ({
step: 1,
totalSteps: 4,
error: '',
loading: false,
sshTesting: false,
sshStatus: '',
form: {
instance_name: 'ProxmoxPanel',
public_url: window.location.origin,
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
ssh_password: '',
proxmox_url: '',
proxmox_token_id: '',
proxmox_token_secret: '',
},
async testSSH() {
this.sshTesting = true
this.sshStatus = ''
try {
const res = await fetch('/api/install/test-ssh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: this.form.ssh_host,
username: this.form.ssh_username,
password: this.form.ssh_password,
}),
})
this.sshStatus = res.ok ? 'ok' : 'error'
} catch {
this.sshStatus = 'error'
} finally {
this.sshTesting = false
}
},
nextStep() {
if (this.step < this.totalSteps) this.step++
},
prevStep() {
if (this.step > 1) this.step--
},
async finish() {
this.loading = true
this.error = ''
try {
const res = await fetch('/api/install/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
})
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.error || 'Erreur installation')
}
window.location.href = '/login.html'
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
t(key, vars) { return Alpine.store('i18n').t(key, vars) },
}))
// ── Composant: dashboardPage ────────────────────────────────────────────
Alpine.data('dashboardPage', () => ({
resources: [],
ws: null,
wsStatus: 'connecting',
init() {
this.connectWS()
},
destroy() {
if (this.ws) this.ws.close()
},
connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'proxmox_resources') {
this.resources = msg.data || []
this.wsStatus = 'ok'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
},
get runningCount() { return this.resources.filter(r => r.status === 'running').length },
get stoppedCount() { return this.resources.filter(r => r.status !== 'running').length },
get lxcList() { return this.resources.filter(r => r.type === 'lxc') },
get vmList() { return this.resources.filter(r => r.type === 'qemu') },
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: proxmoxPage ──────────────────────────────────────────────
Alpine.data('proxmoxPage', () => ({
resources: [],
ws: null,
wsStatus: 'connecting',
actionLoading: {},
init() { this.connectWS() },
destroy() { if (this.ws) this.ws.close() },
connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'proxmox_resources') {
this.resources = msg.data || []
this.wsStatus = 'ok'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
},
async action(vmid, type, action) {
const key = `${vmid}-${action}`
this.actionLoading[key] = true
try {
await apiFetch(`/api/proxmox/${type}/${vmid}/${action}`, { method: 'POST' })
} catch(e) {
console.error(e)
} finally {
this.actionLoading[key] = false
}
},
cpuColor(pct) {
if (pct > 80) return 'var(--color-error)'
if (pct > 50) return 'var(--color-warning)'
return 'var(--color-success)'
},
formatMem(bytes) {
if (!bytes) return '0 MB'
return Math.round(bytes / 1024 / 1024) + ' MB'
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: updatesPage ──────────────────────────────────────────────
Alpine.data('updatesPage', () => ({
targets: [],
loading: true,
ws: null,
currentJob: null,
output: '',
jobStatus: '',
async init() {
await this.loadTargets()
await this.checkAll()
},
destroy() { if (this.ws) this.ws.close() },
async loadTargets() {
this.loading = true
try {
const res = await apiFetch('/api/proxmox/resources')
if (res.ok) {
const resources = await res.json() || []
this.targets = [
{ id: 'host', name: 'Proxmox Host', status: 'running', packages: null, checking: false, updating: false },
...resources
.filter(r => r.type === 'lxc')
.map(r => ({
id: `lxc:${r.vmid}`,
name: r.name || `LXC ${r.vmid}`,
status: r.status,
vmid: r.vmid,
packages: null,
checking: false,
updating: false,
})),
]
}
} catch(e) {
console.error('loadTargets', e)
} finally {
this.loading = false
}
},
async checkTarget(target) {
target.checking = true
target.packages = null
try {
const res = await apiFetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`)
if (res.ok) {
target.packages = await res.json()
}
} catch(e) {
console.error('checkTarget', e)
} finally {
target.checking = false
}
},
async checkAll() {
for (const t of this.targets) {
await this.checkTarget(t)
}
},
async updateTarget(target) {
target.updating = true
this.output = ''
this.jobStatus = 'running'
try {
const res = await apiFetch('/api/updates/run', {
method: 'POST',
body: JSON.stringify({ target: target.id }),
})
if (!res.ok) throw new Error('Erreur démarrage mise à jour')
const data = await res.json()
this.currentJob = data.job_id
await this.watchJob(data.job_id)
target.packages = []
} catch(e) {
this.output += '\n[ERREUR] ' + e.message
this.jobStatus = 'error'
} finally {
target.updating = false
}
},
async updateAll() {
this.output = ''
this.jobStatus = 'running'
try {
const res = await apiFetch('/api/updates/run', {
method: 'POST',
body: JSON.stringify({ target: 'all' }),
})
if (!res.ok) throw new Error('Erreur démarrage')
const data = await res.json()
this.currentJob = data.job_id
await this.watchJob(data.job_id)
for (const t of this.targets) t.packages = []
} catch(e) {
this.output += '\n[ERREUR] ' + e.message
this.jobStatus = 'error'
}
},
watchJob(jobId) {
return new Promise((resolve) => {
if (this.ws) this.ws.close()
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = localStorage.getItem('pxp_token')
this.ws = new WebSocket(`${proto}://${location.host}/ws/updates/${jobId}?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'update_output') {
this.output += msg.data?.chunk || ''
} else if (msg.type === 'update_done') {
this.jobStatus = 'success'
resolve()
} else if (msg.type === 'update_error') {
this.jobStatus = 'error'
this.output += '\n[ERREUR] ' + (msg.data?.error || '')
resolve()
}
}
this.ws.onerror = () => { this.jobStatus = 'error'; resolve() }
this.ws.onclose = () => { if (this.jobStatus === 'running') { this.jobStatus = 'error'; resolve() } }
})
},
get totalPackages() {
return this.targets.reduce((sum, t) => sum + (t.packages?.length || 0), 0)
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: settingsPage ─────────────────────────────────────────────
Alpine.data('settingsPage', () => ({
tab: 'general',
loading: true,
saving: false,
saved: false,
error: '',
settings: {
instance_name: '',
public_url: '',
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
ssh_password: '',
proxmox_url: '',
proxmox_token_id: '',
proxmox_token_secret: '',
},
async init() {
await this.load()
},
async load() {
this.loading = true
try {
const res = await apiFetch('/api/settings')
if (res.ok) {
const data = await res.json()
Object.assign(this.settings, data)
}
} finally {
this.loading = false
}
},
async save() {
this.saving = true
this.saved = false
this.error = ''
try {
const res = await apiFetch('/api/settings', {
method: 'PUT',
body: JSON.stringify(this.settings),
})
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.error || 'Erreur sauvegarde')
}
this.saved = true
setTimeout(() => { this.saved = false }, 3000)
} catch(e) {
this.error = e.message
} finally {
this.saving = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: modulesPage ──────────────────────────────────────────────
Alpine.data('modulesPage', () => ({
modules: [],
loading: true,
toggling: {},
async init() {
await this.load()
},
async load() {
this.loading = true
try {
const res = await apiFetch('/api/modules')
if (res.ok) {
this.modules = await res.json() || []
}
} finally {
this.loading = false
}
},
async toggle(mod) {
this.toggling[mod.id] = true
try {
const action = mod.enabled ? 'disable' : 'enable'
const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' })
if (res.ok) {
mod.enabled = !mod.enabled
}
} catch(e) {
console.error(e)
} finally {
this.toggling[mod.id] = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))
}) // end alpine:init
// ── DOMContentLoaded : init stores + Swup ─────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Init stores
await Alpine.store('i18n').init()
await Alpine.store('auth').init()
Alpine.store('ui').init()
// Guard auth : redirect si non authentifié
const publicPages = ['login', 'install', 'index', '']
const currentPage = window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
if (!publicPages.includes(currentPage)) {
if (!Alpine.store('auth').isAuthenticated) {
window.location.href = '/login.html'
return
}
}
// Redirect depuis index
if (currentPage === 'index' || currentPage === '') {
const res = await fetch('/api/install/status').catch(() => null)
if (res && res.ok) {
const data = await res.json().catch(() => ({}))
window.location.href = data.installed ? '/login.html' : '/install.html'
} else {
window.location.href = '/login.html'
}
return
}
// Init Swup pour transitions de page
if (typeof Swup !== 'undefined') {
const swup = new Swup({
containers: ['#swup'],
animationSelector: '[class*="transition-"]',
})
window.swup = swup
// Guard auth sur navigation
swup.hooks.on('visit:start', (visit) => {
const dest = new URL(visit.to.url, location.href).pathname
.replace(/^\/|\.html$/g, '') || 'index'
if (!publicPages.includes(dest) && !Alpine.store('auth').isAuthenticated) {
visit.abort()
window.location.href = '/login.html'
}
})
// Destroy Alpine scope de l'ancien contenu AVANT le swap
swup.hooks.on('animation:out:end', () => {
const container = document.getElementById('swup')
if (container && typeof Alpine.destroyTree === 'function') {
Alpine.destroyTree(container)
}
})
// Init Alpine sur le nouveau contenu APRÈS le swap
swup.hooks.on('content:replace', () => {
const container = document.getElementById('swup')
if (container) {
Alpine.initTree(container)
}
// Update current page dans UI store
Alpine.store('ui').currentPage =
window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
})
}
// HTMX : inject Authorization header sur toutes les requêtes
document.addEventListener('htmx:configRequest', (e) => {
const token = localStorage.getItem('pxp_token')
if (token) {
e.detail.headers['Authorization'] = 'Bearer ' + token
}
})
})

110
frontend/js/terminal.js Normal file
View file

@ -0,0 +1,110 @@
/**
* ProxmoxPanel Terminal page logic
* xterm.js + WebSocket PTY
* Chargé uniquement sur terminal.html
*/
document.addEventListener('DOMContentLoaded', () => {
// Les classes Terminal et FitAddon sont exposées par xterm.iife.js
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
console.error('xterm.js not loaded')
return
}
const term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
fontSize: 14,
theme: {
background: 'var(--bg-secondary, #1a1a2e)',
foreground: 'var(--text-primary, #e2e8f0)',
cursor: 'var(--accent-primary, #6366f1)',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
const container = document.getElementById('terminal-container')
if (!container) return
term.open(container)
fitAddon.fit()
// Connexion WebSocket PTY
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = localStorage.getItem('pxp_token')
const ws = new WebSocket(`${proto}://${location.host}/ws/terminal?token=${encodeURIComponent(token || '')}`)
ws.binaryType = 'arraybuffer'
const statusEl = document.getElementById('terminal-status')
function setStatus(text, cls) {
if (statusEl) {
statusEl.textContent = text
statusEl.className = 'terminal-status ' + (cls || '')
}
}
setStatus('Connexion…', 'connecting')
ws.onopen = () => {
setStatus('Connecté', 'connected')
// Envoyer la taille initiale du terminal
sendResize()
}
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
term.write(new Uint8Array(e.data))
} else {
term.write(e.data)
}
}
ws.onclose = () => {
setStatus('Déconnecté', 'disconnected')
term.writeln('\r\n\x1b[31m[Connexion terminée]\x1b[0m')
}
ws.onerror = () => {
setStatus('Erreur', 'error')
}
// Envoyer l'input utilisateur au serveur
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
// Envoyer la taille du terminal lors du redimensionnement
function sendResize() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}))
}
}
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
sendResize()
})
resizeObserver.observe(container)
// Nettoyage quand Swup navigue hors de la page
document.addEventListener('swup:page:view', () => {
if (!document.getElementById('terminal-container')) {
ws.close()
resizeObserver.disconnect()
}
})
// Bouton "Effacer"
const clearBtn = document.getElementById('terminal-clear')
if (clearBtn) {
clearBtn.addEventListener('click', () => term.clear())
}
})

131
frontend/login.html Normal file
View file

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Connexion</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data>
<div class="auth-layout" x-data="loginPage()" x-cloak>
<div class="auth-card neu-card">
<div class="auth-logo">
<span class="logo-icon"></span>
<h1 class="auth-title">ProxmoxPanel</h1>
<p class="auth-subtitle" x-text="t('login.subtitle')"></p>
</div>
<form @submit.prevent="submit" class="auth-form">
<div class="form-group">
<label class="form-label" x-text="t('login.username')"></label>
<input
class="neu-input"
type="text"
x-model="username"
:placeholder="t('login.usernamePlaceholder')"
autocomplete="username"
required
/>
</div>
<div class="form-group">
<label class="form-label" x-text="t('login.password')"></label>
<input
class="neu-input"
type="password"
x-model="password"
:placeholder="t('login.passwordPlaceholder')"
autocomplete="current-password"
required
/>
</div>
<div class="form-error" x-show="error" x-text="error" role="alert"></div>
<button class="neu-btn neu-btn--primary auth-submit" type="submit" :disabled="loading">
<span x-show="!loading" x-text="t('login.submit')"></span>
<span x-show="loading" class="spinner"></span>
</button>
</form>
</div>
</div>
<style>
.auth-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.auth-logo {
text-align: center;
margin-bottom: 2rem;
}
.logo-icon {
font-size: 3rem;
color: var(--accent-primary);
}
.auth-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0.5rem 0 0.25rem;
}
.auth-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
}
.form-error {
background: rgba(239,68,68,0.1);
border: 1px solid var(--color-error, #ef4444);
border-radius: 0.5rem;
padding: 0.75rem;
font-size: 0.875rem;
color: var(--color-error, #ef4444);
}
.auth-submit {
width: 100%;
padding: 0.875rem;
margin-top: 0.5rem;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
[x-cloak] { display: none !important; }
</style>
</body>
</html>

109
frontend/modules.html Normal file
View file

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Modules</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<span class="sidebar-logo"></span>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()"></button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<span class="sidebar-icon" x-text="item.icon"></span>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.modules')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()">
<span x-text="theme==='dark'?'☀':'🌙'"></span>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()"></button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="modulesPage()" x-cloak>
<div class="loading-state" x-show="loading">
<div class="spinner-lg"></div>
<span>Chargement…</span>
</div>
<div class="modules-grid" x-show="!loading">
<template x-for="mod in modules" :key="mod.id">
<div class="neu-card module-card" :class="{ disabled: !mod.enabled }">
<div class="module-header">
<div class="module-icon" x-text="mod.icon || '⬡'"></div>
<div class="module-info">
<span class="module-name" x-text="mod.name || mod.id"></span>
<span class="module-desc" x-text="mod.description || ''"></span>
</div>
<div class="module-toggle">
<span class="core-badge" x-show="mod.core">CORE</span>
<button class="toggle-btn" :class="{ on: mod.enabled }"
@click="toggle(mod)" :disabled="mod.core || toggling[mod.id]"
x-show="!mod.core">
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label" x-text="mod.enabled ? 'Activé' : 'Désactivé'"></span>
</button>
</div>
</div>
</div>
</template>
<p class="empty-state" x-show="modules.length === 0">Aucun module trouvé</p>
</div>
</div>
</main>
</div>
<style>
[x-cloak]{display:none!important}
.main-layout{display:flex;flex-direction:column;flex:1;margin-left:var(--sidebar-width,240px);transition:margin-left .2s}
.modules-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
.module-card{padding:1rem;transition:opacity .2s}
.module-card.disabled{opacity:.6}
.module-header{display:flex;align-items:center;gap:.75rem}
.module-icon{font-size:1.75rem;flex-shrink:0}
.module-info{flex:1}
.module-name{font-weight:700;display:block;margin-bottom:.2rem}
.module-desc{font-size:.8rem;color:var(--text-secondary);display:block}
.core-badge{font-size:.65rem;padding:.15rem .4rem;border-radius:.2rem;background:rgba(99,102,241,.15);color:var(--accent-primary);font-weight:700;text-transform:uppercase}
.toggle-btn{display:flex;align-items:center;gap:.4rem;background:none;border:none;cursor:pointer;color:var(--text-secondary);font-size:.8rem}
.toggle-btn:disabled{cursor:not-allowed;opacity:.5}
.toggle-track{width:2.5rem;height:1.25rem;background:var(--bg-secondary);border-radius:.625rem;position:relative;transition:background .2s;border:1px solid var(--border-color)}
.toggle-thumb{position:absolute;top:.125rem;left:.125rem;width:.875rem;height:.875rem;background:var(--text-muted);border-radius:50%;transition:transform .2s,background .2s}
.toggle-btn.on .toggle-track{background:var(--accent-primary);border-color:var(--accent-primary)}
.toggle-btn.on .toggle-thumb{transform:translateX(1.25rem);background:#fff}
.loading-state{display:flex;align-items:center;gap:.75rem;padding:2rem;color:var(--text-muted)}
.spinner-lg{width:2rem;height:2rem;border:3px solid transparent;border-top-color:var(--accent-primary);border-radius:50%;animation:spin .6s linear infinite}
.empty-state{color:var(--text-muted);font-size:.875rem}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</body>
</html>

View file

@ -1,6 +1,3 @@
# 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;
@ -14,7 +11,6 @@ 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"';
@ -23,7 +19,6 @@ http {
sendfile on;
keepalive_timeout 65;
# Compression gzip pour les assets statiques
gzip on;
gzip_vary on;
gzip_min_length 1024;
@ -36,13 +31,13 @@ http {
root /usr/share/nginx/html;
index index.html;
# Cache agressif pour les assets avec hash dans le nom (Vite)
# Cache agressif pour JS/CSS
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
# Proxy API backend Go
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
@ -50,10 +45,10 @@ http {
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_read_timeout 300s;
}
# Proxy des connexions WebSocket
# Proxy WebSocket backend Go
location /ws/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
@ -61,13 +56,13 @@ http {
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_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# SPA : toutes les autres routes servent index.html (Vue Router)
# URLs propres : /dashboard /dashboard.html
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri.html $uri/ /index.html;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,15 @@
{
"name": "proxmoxpanel-frontend",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"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"
"build": "node build.mjs"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.3.3",
"vue-tsc": "^2.2.10"
"swup": "^4.8.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"esbuild": "^0.24.0"
}
}

158
frontend/proxmox.html Normal file
View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Proxmox</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<span class="sidebar-logo"></span>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()"></button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<span class="sidebar-icon" x-text="item.icon"></span>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.proxmox')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<span x-text="theme==='dark'?'☀':'🌙'"></span>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')"></button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="proxmoxPage()" x-cloak>
<div class="ws-status" :class="wsStatus">
<span x-show="wsStatus==='connecting'">⌛ Connexion WebSocket…</span>
<span x-show="wsStatus==='ok'" style="color:var(--color-success)">● Live</span>
<span x-show="wsStatus==='disconnected'" style="color:var(--color-warning)">⚠ Reconnexion…</span>
<span x-show="wsStatus==='error'" style="color:var(--color-error)">✗ Erreur WebSocket</span>
</div>
<!-- LXC -->
<div class="section">
<h3 class="section-title">Containers LXC</h3>
<div class="resource-grid">
<template x-for="r in resources.filter(r=>r.type==='lxc')" :key="r.vmid">
<div class="neu-card resource-card" :class="r.status">
<div class="resource-header">
<span class="resource-name" x-text="r.name||'LXC '+r.vmid"></span>
<span class="badge" :class="r.status" x-text="r.status"></span>
</div>
<div class="resource-metrics" x-show="r.status==='running'">
<div class="metric">
<span class="metric-label">CPU</span>
<div class="metric-bar"><div class="metric-fill" :style="'width:'+Math.round((r.cpu||0)*100)+'%;background:'+cpuColor(Math.round((r.cpu||0)*100))"></div></div>
<span class="metric-val" x-text="Math.round((r.cpu||0)*100)+'%'"></span>
</div>
<div class="metric">
<span class="metric-label">RAM</span>
<div class="metric-bar"><div class="metric-fill" :style="'width:'+Math.round((r.mem||0)/(r.maxmem||1)*100)+'%'"></div></div>
<span class="metric-val" x-text="formatMem(r.mem)"></span>
</div>
</div>
<div class="resource-actions">
<button class="neu-btn neu-btn--sm neu-btn--success" x-show="r.status!=='running'"
@click="action(r.vmid,'lxc','start')" :disabled="actionLoading[r.vmid+'-start']">
▶ Start
</button>
<button class="neu-btn neu-btn--sm neu-btn--danger" x-show="r.status==='running'"
@click="action(r.vmid,'lxc','stop')" :disabled="actionLoading[r.vmid+'-stop']">
⏹ Stop
</button>
<button class="neu-btn neu-btn--sm" x-show="r.status==='running'"
@click="action(r.vmid,'lxc','reboot')" :disabled="actionLoading[r.vmid+'-reboot']">
↺ Reboot
</button>
</div>
</div>
</template>
</div>
<p class="empty-state" x-show="resources.filter(r=>r.type==='lxc').length===0&&wsStatus==='ok'">Aucun container LXC</p>
</div>
<!-- VMs -->
<div class="section">
<h3 class="section-title">Machines virtuelles</h3>
<div class="resource-grid">
<template x-for="r in resources.filter(r=>r.type==='qemu')" :key="r.vmid">
<div class="neu-card resource-card" :class="r.status">
<div class="resource-header">
<span class="resource-name" x-text="r.name||'VM '+r.vmid"></span>
<span class="badge" :class="r.status" x-text="r.status"></span>
</div>
<div class="resource-metrics" x-show="r.status==='running'">
<div class="metric">
<span class="metric-label">CPU</span>
<div class="metric-bar"><div class="metric-fill" :style="'width:'+Math.round((r.cpu||0)*100)+'%;background:'+cpuColor(Math.round((r.cpu||0)*100))"></div></div>
<span class="metric-val" x-text="Math.round((r.cpu||0)*100)+'%'"></span>
</div>
</div>
<div class="resource-actions">
<button class="neu-btn neu-btn--sm neu-btn--success" x-show="r.status!=='running'"
@click="action(r.vmid,'qemu','start')" :disabled="actionLoading[r.vmid+'-start']">
▶ Start
</button>
<button class="neu-btn neu-btn--sm neu-btn--danger" x-show="r.status==='running'"
@click="action(r.vmid,'qemu','stop')" :disabled="actionLoading[r.vmid+'-stop']">
⏹ Stop
</button>
</div>
</div>
</template>
</div>
<p class="empty-state" x-show="resources.filter(r=>r.type==='qemu').length===0&&wsStatus==='ok'">Aucune VM</p>
</div>
</div>
</main>
</div>
<style>
[x-cloak]{display:none!important}
.main-layout{display:flex;flex-direction:column;flex:1;margin-left:var(--sidebar-width,240px);transition:margin-left .2s}
.resource-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.resource-card{padding:1rem}
.resource-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}
.resource-name{font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{font-size:.7rem;padding:.2rem .5rem;border-radius:.25rem;text-transform:uppercase;font-weight:600}
.badge.running{background:rgba(34,197,94,.15);color:var(--color-success,#22c55e)}
.badge.stopped{background:rgba(239,68,68,.1);color:var(--color-error,#ef4444)}
.resource-metrics{margin-bottom:.75rem}
.metric{display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem}
.metric-label{font-size:.7rem;color:var(--text-secondary);min-width:30px}
.metric-bar{flex:1;height:6px;border-radius:3px;background:var(--bg-secondary);overflow:hidden}
.metric-fill{height:100%;border-radius:3px;background:var(--accent-primary);transition:width .5s}
.metric-val{font-size:.7rem;min-width:36px;text-align:right}
.resource-actions{display:flex;gap:.5rem;flex-wrap:wrap}
.section{margin-bottom:2rem}
.section-title{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:.75rem}
.ws-status{font-size:.8rem;margin-bottom:1rem}
.empty-state{color:var(--text-muted);font-size:.875rem}
</style>
</body>
</html>

163
frontend/settings.html Normal file
View file

@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Paramètres</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<span class="sidebar-logo"></span>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()"></button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<span class="sidebar-icon" x-text="item.icon"></span>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.settings')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()">
<span x-text="theme==='dark'?'☀':'🌙'"></span>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()"></button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="settingsPage()" x-cloak>
<!-- Loading -->
<div class="loading-state" x-show="loading">
<div class="spinner-lg"></div>
<span>Chargement…</span>
</div>
<template x-if="!loading">
<div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn" :class="{ active: tab === 'general' }" @click="tab = 'general'">Général</button>
<button class="tab-btn" :class="{ active: tab === 'ssh' }" @click="tab = 'ssh'">SSH</button>
<button class="tab-btn" :class="{ active: tab === 'proxmox' }" @click="tab = 'proxmox'">Proxmox API</button>
</div>
<!-- Général -->
<div class="tab-panel" x-show="tab === 'general'">
<div class="form-grid">
<div class="form-group">
<label class="form-label" x-text="t('install.instanceName')"></label>
<input class="neu-input" type="text" x-model="settings.instance_name" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.publicUrl')"></label>
<input class="neu-input" type="url" x-model="settings.public_url" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.defaultLang')"></label>
<select class="neu-input" x-model="settings.default_lang">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
<!-- SSH -->
<div class="tab-panel" x-show="tab === 'ssh'">
<div class="form-grid">
<div class="form-group">
<label class="form-label" x-text="t('install.sshHost')"></label>
<input class="neu-input" type="text" x-model="settings.ssh_host" placeholder="host:port" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshUsername')"></label>
<input class="neu-input" type="text" x-model="settings.ssh_username" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshPassword')"></label>
<input class="neu-input" type="password" x-model="settings.ssh_password"
placeholder="Laisser vide pour ne pas modifier" />
</div>
</div>
</div>
<!-- Proxmox API -->
<div class="tab-panel" x-show="tab === 'proxmox'">
<div class="form-grid">
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxUrl')"></label>
<input class="neu-input" type="url" x-model="settings.proxmox_url" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxTokenId')"></label>
<input class="neu-input" type="text" x-model="settings.proxmox_token_id" placeholder="user@realm!tokenid" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxTokenSecret')"></label>
<input class="neu-input" type="text" x-model="settings.proxmox_token_secret"
placeholder="Laisser vide pour ne pas modifier" />
</div>
</div>
</div>
<!-- Save actions -->
<div class="save-bar">
<div class="save-feedback">
<span class="save-success" x-show="saved">✓ Paramètres sauvegardés</span>
<span class="save-error" x-show="error" x-text="error"></span>
</div>
<button class="neu-btn neu-btn--primary" @click="save()" :disabled="saving">
<span x-show="!saving">💾 Sauvegarder</span>
<span x-show="saving"><span class="spinner-sm"></span></span>
</button>
</div>
</div>
</template>
</div>
</main>
</div>
<style>
[x-cloak]{display:none!important}
.main-layout{display:flex;flex-direction:column;flex:1;margin-left:var(--sidebar-width,240px);transition:margin-left .2s}
.tabs{display:flex;gap:.5rem;margin-bottom:1.5rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem}
.tab-btn{padding:.5rem 1rem;border-radius:.375rem .375rem 0 0;font-size:.875rem;font-weight:600;color:var(--text-secondary);background:transparent;border:none;cursor:pointer;transition:all .15s}
.tab-btn.active{color:var(--accent-primary);background:var(--bg-secondary);border-bottom:2px solid var(--accent-primary)}
.tab-btn:hover:not(.active){color:var(--text-primary)}
.tab-panel{animation:fadeIn .15s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.form-grid{display:grid;gap:1.25rem;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));margin-bottom:1.5rem}
.form-group{display:flex;flex-direction:column;gap:.4rem}
.form-label{font-size:.8rem;font-weight:600;color:var(--text-secondary)}
.save-bar{display:flex;align-items:center;justify-content:flex-end;gap:1rem;padding-top:1rem;border-top:1px solid var(--border-color)}
.save-feedback{flex:1}
.save-success{color:var(--color-success,#22c55e);font-size:.875rem}
.save-error{color:var(--color-error,#ef4444);font-size:.875rem}
.loading-state{display:flex;align-items:center;gap:.75rem;padding:2rem;color:var(--text-muted)}
.spinner-lg{width:2rem;height:2rem;border:3px solid transparent;border-top-color:var(--accent-primary);border-radius:50%;animation:spin .6s linear infinite}
.spinner-sm{display:inline-block;width:.875rem;height:.875rem;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</body>
</html>

View file

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

View file

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

View file

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

View file

@ -1,292 +0,0 @@
<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: 'settings',
path: '/settings',
label: 'nav.settings',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
},
]
// Afficher uniquement les routes admin si l'utilisateur est admin
if (authStore.user?.is_admin) {
items.push({
name: 'modules',
path: '/modules',
label: 'nav.modules',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 21h7v-7h-7z"/></svg>`,
})
}
return items
})
function isActive(path: string): boolean {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<style scoped>
.sidebar {
height: 100%;
display: flex;
flex-direction: column;
background: var(--neu-surface);
border-right: 1px solid var(--neu-border);
overflow: hidden;
}
[data-sidebar="right"] .sidebar {
border-right: none;
border-left: 1px solid var(--neu-border);
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--neu-space-md);
height: 64px;
flex-shrink: 0;
border-bottom: 1px solid var(--neu-border);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
overflow: hidden;
}
.sidebar__logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: var(--neu-primary);
flex-shrink: 0;
padding: 0;
border-radius: var(--neu-radius-sm);
}
.sidebar__logo-text {
font-size: var(--neu-font-md);
font-weight: 600;
color: var(--neu-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__collapse-btn {
flex-shrink: 0;
opacity: 0.6;
}
.sidebar__collapse-btn:hover { opacity: 1; }
.sidebar__nav {
flex: 1;
padding: var(--neu-space-sm);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar__nav-item {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
padding: 10px var(--neu-space-sm);
border-radius: var(--neu-radius-md);
color: var(--neu-text-muted);
text-decoration: none;
transition: var(--neu-transition);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.sidebar__nav-item:hover {
background: var(--neu-bg);
color: var(--neu-text);
box-shadow:
3px 3px 6px var(--neu-shadow-dark),
-2px -2px 4px var(--neu-shadow-light);
}
.sidebar__nav-item--active {
background: var(--neu-bg);
color: var(--neu-primary);
box-shadow:
inset 2px 2px 5px var(--neu-shadow-dark),
inset -1px -1px 3px var(--neu-shadow-light);
}
.sidebar__nav-icon {
flex-shrink: 0;
display: flex;
align-items: center;
width: 18px;
}
.sidebar__nav-label {
font-size: var(--neu-font-md);
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__footer {
padding: var(--neu-space-sm) var(--neu-space-md);
border-top: 1px solid var(--neu-border);
}
.sidebar__user {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
overflow: hidden;
}
.sidebar__user-avatar {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
color: var(--neu-primary);
flex-shrink: 0;
padding: 0;
border-radius: 50%;
}
.sidebar__user-info {
overflow: hidden;
min-width: 0;
}
.sidebar__user-name {
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__user-role {
margin-top: 2px;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View file

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

View file

@ -1,129 +0,0 @@
// 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()
// Au premier chargement : vérifier l'installation ET restaurer la session
if (!authStore.installChecked) {
await authStore.checkInstallation()
await authStore.restoreSession()
}
// Rediriger vers l'installation si pas encore configuré
if (!authStore.isInstalled && to.name !== 'install') {
return { name: 'install' }
}
// Si installé et route d'install → rediriger vers le dashboard
if (authStore.isInstalled && to.name === 'install') {
return { name: 'dashboard' }
}
// Routes publiques — passer directement
if (to.meta.public) return true
// Routes protégées — vérifier l'authentification
if (to.meta.requiresAuth || to.matched.some(r => r.meta.requiresAuth)) {
if (!authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
// Routes admin uniquement
if (to.meta.requiresAdmin && !authStore.user?.is_admin) {
return { name: 'dashboard' }
}
return true
})
export default router

View file

@ -1,218 +0,0 @@
// 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 contentType = res.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const err = await res.json()
throw new Error(err.error || 'Erreur d\'authentification')
}
throw new Error(`Erreur ${res.status} — réponse inattendue du serveur`)
}
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)
}
/**
* Restaure la session au démarrage de l'application (après F5).
* 1. Essaie fetchMe() avec le token existant (marche si < 15 min)
* 2. Si le token est expiré, tente le refresh via le cookie httpOnly
*/
async function restoreSession(): Promise<void> {
if (!accessToken.value) return
// Le token est peut-être encore valide : évite d'avoir besoin du cookie
await fetchMe()
if (user.value) {
scheduleRefresh(14 * 60 * 1000)
return
}
// Token expiré — tenter le refresh via le cookie httpOnly
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
await fetchMe()
if (user.value) scheduleRefresh(14 * 60 * 1000)
} else {
// Le refresh a explicitement échoué (cookie absent ou expiré)
clearSession()
}
} catch {
// Erreur réseau transitoire — ne pas effacer le token, laisser le guard rediriger
}
}
/**
* Tente de renouveler le token via le cookie httpOnly (pxp_refresh).
* Utilisé par le timer automatique (14 min après login).
*/
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',
})
if (res.ok) {
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
await fetchMe()
scheduleRefresh(14 * 60 * 1000)
} else {
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,
restoreSession,
tryRefresh,
fetchMe,
updatePreferences,
}
})

View file

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

View file

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

View file

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

View file

@ -1,487 +0,0 @@
<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.proxmoxTokenId') }}</label>
<input v-model="form.proxmoxTokenId" class="neu-input" placeholder="enzo@pam!panel" />
</div>
<div class="form-group">
<label>{{ t('install.proxmoxTokenSecret') }}</label>
<input v-model="form.proxmoxTokenSecret" type="password" class="neu-input" placeholder="ed57ea62-cadc-4ddd-..." />
<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',
proxmoxTokenId: '',
proxmoxTokenSecret: '',
})
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.proxmoxTokenId && form.value.proxmoxTokenSecret)
? `PVEAPIToken=${form.value.proxmoxTokenId}=${form.value.proxmoxTokenSecret}`
: '',
}),
})
if (!res.ok) {
const data = await res.json()
error.value = data.error || t('install.error')
return
}
// Marquer comme installé et rediriger vers le login
authStore.isInstalled = true
localStorage.setItem('pxp_instance_name', form.value.instanceName)
router.push('/login')
} catch (e) {
error.value = t('install.networkError')
} finally {
installing.value = false
}
}
</script>
<style scoped>
.install-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--neu-bg);
padding: var(--neu-space-lg);
}
.install-container {
width: 100%;
max-width: 520px;
}
.install-header {
text-align: center;
margin-bottom: var(--neu-space-xl);
}
.install-logo {
width: 64px;
height: 64px;
background: var(--neu-primary);
color: #fff;
font-size: 24px;
font-weight: 700;
border-radius: var(--neu-radius-lg);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--neu-space-md);
box-shadow: 4px 4px 12px rgba(108, 142, 244, 0.4);
}
.install-header h1 {
font-size: var(--neu-font-2xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.install-subtitle {
color: var(--neu-text-muted);
font-size: var(--neu-font-md);
}
.install-steps {
display: flex;
justify-content: space-between;
margin-bottom: var(--neu-space-lg);
position: relative;
}
.install-steps::before {
content: '';
position: absolute;
top: 14px;
left: 14px;
right: 14px;
height: 2px;
background: var(--neu-border);
z-index: 0;
}
.install-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 1;
}
.install-step__dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: var(--neu-surface);
border: 2px solid var(--neu-border);
color: var(--neu-text-muted);
transition: var(--neu-transition);
}
.install-step--active .install-step__dot {
border-color: var(--neu-primary);
color: var(--neu-primary);
}
.install-step--done .install-step__dot {
background: var(--neu-success);
border-color: var(--neu-success);
color: #fff;
}
.install-step__label {
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
}
.install-step--active .install-step__label {
color: var(--neu-primary);
font-weight: 600;
}
.install-card {
padding: var(--neu-space-xl);
}
.install-card h2 {
font-size: var(--neu-font-xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.step-desc {
color: var(--neu-text-muted);
margin-bottom: var(--neu-space-lg);
}
.form-group {
margin-bottom: var(--neu-space-md);
}
.form-group label {
display: block;
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--neu-space-xs);
}
.form-group small {
display: block;
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
margin-top: 4px;
}
.install-result {
margin: var(--neu-space-md) 0;
padding: var(--neu-space-sm) var(--neu-space-md);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-sm);
}
.install-result--success {
background: rgba(76, 187, 138, 0.1);
color: var(--neu-success);
border: 1px solid rgba(76, 187, 138, 0.3);
}
.install-result--error {
background: rgba(240, 92, 107, 0.1);
color: var(--neu-danger);
border: 1px solid rgba(240, 92, 107, 0.3);
}
.install-summary {
background: var(--neu-bg);
border-radius: var(--neu-radius-md);
padding: var(--neu-space-md);
margin-bottom: var(--neu-space-lg);
}
.summary-item {
display: flex;
justify-content: space-between;
padding: var(--neu-space-xs) 0;
border-bottom: 1px solid var(--neu-border);
font-size: var(--neu-font-sm);
}
.summary-item:last-child { border-bottom: none; }
.summary-key { color: var(--neu-text-muted); }
.summary-value { color: var(--neu-text); font-weight: 500; }
.install-actions {
display: flex;
align-items: center;
margin-top: var(--neu-space-xl);
gap: var(--neu-space-sm);
}
.spacer { flex: 1; }
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,403 +0,0 @@
<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.sshPassword') }}</label>
<input v-model="secrets.ssh_password" type="password" class="neu-input" :placeholder="t('settings.secretPlaceholder')" autocomplete="new-password" />
</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 class="form-row">
<label>{{ t('settings.proxmoxTokenId') }}</label>
<input v-model="secrets.proxmox_token_id" class="neu-input" :placeholder="t('settings.proxmoxTokenIdPlaceholder')" autocomplete="off" />
</div>
<div class="form-row">
<label>{{ t('settings.proxmoxTokenSecret') }}</label>
<input v-model="secrets.proxmox_token_secret" type="password" class="neu-input" :placeholder="t('settings.secretPlaceholder')" autocomplete="new-password" />
</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>
<!-- Logs applicatifs -->
<div v-if="activeTab === 'logs'">
<div class="logs-header">
<h3>{{ t('settings.logs') }}</h3>
<div class="logs-controls">
<label class="text-muted">{{ t('settings.logsRefresh') }}</label>
<select v-model="logsRefreshInterval" class="neu-input neu-input--sm" @change="resetLogsRefresh">
<option :value="5000">5s</option>
<option :value="10000">10s</option>
<option :value="30000">30s</option>
<option :value="60000">60s</option>
<option :value="0">{{ t('settings.logsNoRefresh') }}</option>
</select>
<button class="neu-btn neu-btn--sm" @click="loadLogs">{{ t('common.refresh') }}</button>
</div>
</div>
<div class="logs-viewer neu-inset" ref="logsViewerRef">
<p v-if="logLines.length === 0" class="text-muted logs-empty">{{ t('settings.noLogs') }}</p>
<div v-else class="logs-lines">
<span v-for="(line, i) in logLines" :key="i" class="log-line">{{ line }}</span>
</div>
</div>
</div>
<!-- Bouton sauvegarder (sauf audit et logs) -->
<div v-if="activeTab !== 'audit' && activeTab !== 'logs'" 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, onUnmounted, watch, nextTick } 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 logLines = ref<string[]>([])
const logsRefreshInterval = ref(10000)
const logsViewerRef = ref<HTMLElement | null>(null)
let logsTimer: ReturnType<typeof setInterval> | null = null
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>` },
{ id: 'logs', label: 'settings.logs', icon: `<svg viewBox="0 0 24 24" width="14" height="14" 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>` },
]
const settings = ref({
instance_name: '',
public_url: '',
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
proxmox_url: '',
})
// Champs sensibles write-only, jamais retournés par l'API
const secrets = ref({
ssh_password: '',
proxmox_token_id: '',
proxmox_token_secret: '',
})
onMounted(async () => {
await loadSettings()
await loadAuditLog()
})
// Charger les logs quand on active l'onglet + gérer le timer
watch(activeTab, (tab) => {
if (tab === 'logs') {
loadLogs()
startLogsRefresh()
} else {
stopLogsRefresh()
}
})
onUnmounted(() => stopLogsRefresh())
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 entries: [string, string][] = [...Object.entries(settings.value)]
// Mot de passe SSH envoyé seulement si non-vide
if (secrets.value.ssh_password) {
entries.push(['ssh_password', secrets.value.ssh_password])
}
// Token Proxmox assemblé si les deux champs sont remplis
if (secrets.value.proxmox_token_id && secrets.value.proxmox_token_secret) {
entries.push([
'proxmox_token',
`PVEAPIToken=${secrets.value.proxmox_token_id}=${secrets.value.proxmox_token_secret}`,
])
}
for (const [key, value] of entries) {
await fetch(`/api/settings/${key}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ value }),
})
}
// Vider les champs secrets après sauvegarde
secrets.value.ssh_password = ''
secrets.value.proxmox_token_id = ''
secrets.value.proxmox_token_secret = ''
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()
}
async function loadLogs() {
const res = await fetch('/api/settings/logs?lines=300', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
logLines.value = await res.json() || []
// Scroll vers le bas après rendu
await nextTick()
if (logsViewerRef.value) {
logsViewerRef.value.scrollTop = logsViewerRef.value.scrollHeight
}
}
}
function startLogsRefresh() {
stopLogsRefresh()
if (logsRefreshInterval.value > 0) {
logsTimer = setInterval(loadLogs, logsRefreshInterval.value)
}
}
function stopLogsRefresh() {
if (logsTimer) {
clearInterval(logsTimer)
logsTimer = null
}
}
function resetLogsRefresh() {
stopLogsRefresh()
startLogsRefresh()
}
</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); }
.logs-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--neu-space-md); flex-wrap: wrap; gap: var(--neu-space-sm); }
.logs-header h3 { margin-bottom: 0; }
.logs-controls { display: flex; align-items: center; gap: var(--neu-space-sm); }
.neu-input--sm { padding: 4px 8px; font-size: var(--neu-font-sm); height: auto; }
.logs-viewer {
height: 420px;
overflow-y: auto;
padding: var(--neu-space-sm);
font-family: monospace;
font-size: 12px;
line-height: 1.5;
}
.logs-empty { padding: var(--neu-space-sm); }
.logs-lines {
display: flex;
flex-direction: column;
gap: 1px;
}
.log-line {
white-space: pre-wrap;
word-break: break-all;
color: var(--neu-text);
padding: 1px 0;
border-bottom: 1px solid var(--neu-border);
}
.log-line:last-child { border-bottom: none; }
@media (max-width: 640px) {
.settings-layout { grid-template-columns: 1fr; }
.settings-tabs { flex-direction: row; flex-wrap: wrap; }
.form-row { grid-template-columns: 1fr; }
}
</style>

View file

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

View file

@ -1,424 +0,0 @@
<template>
<div class="updates-page">
<div class="page-header">
<div>
<h2>{{ t('nav.updates') }}</h2>
<p class="text-muted">{{ t('updates.desc') }}</p>
</div>
<div class="header-actions">
<button class="neu-btn neu-btn--sm" :disabled="checkingAll || loadingTargets" @click="checkAll">
<span v-if="checkingAll" class="neu-loading"></span>
{{ t('updates.checkAll') }}
</button>
<button class="neu-btn neu-btn--success" :disabled="anyRunning" @click="updateAllTargets">
{{ anyRunning ? t('updates.running') : t('updates.updateAll') }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loadingTargets" class="neu-card loading-card">
<p class="text-muted"> {{ t('updates.loadingTargets') }}</p>
</div>
<!-- Target cards -->
<div v-else class="targets-grid">
<div
v-for="target in targets"
:key="target.id"
class="target-card neu-card"
>
<!-- Header -->
<div class="target-header">
<div class="target-title">
<span class="target-name">{{ target.name }}</span>
<span v-if="target.vmid" class="target-vmid">LXC {{ target.vmid }}</span>
</div>
<span :class="['neu-badge', target.status === 'running' ? 'neu-badge--success' : 'neu-badge--warning']">
{{ target.status === 'running' ? t('proxmox.running') : t('updates.stopped') }}
</span>
</div>
<!-- Package status -->
<div class="target-status">
<template v-if="target.checking">
<span class="text-muted text-xs"> {{ t('updates.checking') }}</span>
</template>
<template v-else-if="target.error">
<span class="neu-badge neu-badge--danger" :title="target.error"> Erreur</span>
</template>
<template v-else-if="target.packages === null">
<span class="not-checked text-muted">{{ t('updates.notChecked') }}</span>
</template>
<template v-else-if="target.packages.length === 0">
<span class="neu-badge neu-badge--success"> {{ t('updates.upToDate') }}</span>
</template>
<template v-else>
<button class="pkg-count-btn" @click="target.showPackages = !target.showPackages">
<span class="neu-badge neu-badge--warning">{{ target.packages.length }} {{ t('updates.packagesToUpdate') }}</span>
<span class="pkg-toggle">{{ target.showPackages ? '▲' : '▼' }}</span>
</button>
</template>
</div>
<!-- Package list (expandable) -->
<div v-if="target.showPackages && target.packages?.length" class="package-list neu-inset">
<div v-for="pkg in target.packages" :key="pkg.name" class="package-item">
<span class="pkg-name">{{ pkg.name }}</span>
<span class="pkg-versions text-muted">{{ pkg.old_version }} <strong>{{ pkg.version }}</strong></span>
</div>
</div>
<!-- Actions -->
<div class="target-actions">
<button
class="neu-btn neu-btn--sm"
:disabled="target.checking || target.status !== 'running'"
@click="checkTarget(target)"
>
{{ t('updates.checkUpdates') }}
</button>
<button
class="neu-btn neu-btn--sm neu-btn--success"
:disabled="target.updating || target.status !== 'running'"
@click="updateTarget(target)"
>
<span v-if="target.updating" class="neu-loading"></span>
{{ target.updating ? t('updates.running') : t('updates.updateTarget') }}
</button>
</div>
</div>
</div>
<!-- Output terminal -->
<div v-if="activeJob" class="neu-card output-card">
<div class="output-header flex items-center justify-between">
<h3>{{ t('updates.output') }} {{ activeJob.target }}</h3>
<span :class="['neu-badge', activeJob.status === 'success' ? 'neu-badge--success' : activeJob.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
{{ t(`updates.status.${activeJob.status}`) }}
</span>
</div>
<div class="output-terminal neu-inset" ref="terminalEl">
<pre class="output-text">{{ activeJob.output }}</pre>
</div>
</div>
<!-- History -->
<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="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, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
interface Package {
name: string
version: string
old_version: string
}
interface Target {
id: string
name: string
status: string
vmid?: number
packages: Package[] | null
checking: boolean
updating: boolean
showPackages: boolean
error: string | null
}
interface ActiveJob {
jobId: string
target: string
output: string
status: 'running' | 'success' | 'error'
}
const targets = ref<Target[]>([])
const loadingTargets = ref(true)
const activeJob = ref<ActiveJob | null>(null)
const history = ref<any[]>([])
const terminalEl = ref<HTMLElement | null>(null)
let wsConnection: WebSocket | null = null
const checkingAll = computed(() => targets.value.some(t => t.checking))
const anyRunning = computed(() => targets.value.some(t => t.updating))
onMounted(async () => {
await loadTargets()
await loadHistory()
checkAll()
})
onUnmounted(() => wsConnection?.close())
async function loadTargets() {
loadingTargets.value = true
try {
const list: Target[] = [
{ id: 'host', name: 'Proxmox Host', status: 'running', packages: null, checking: false, updating: false, showPackages: false, error: null },
]
const res = await fetch('/api/proxmox/resources', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
const resources: any[] = await res.json() || []
for (const r of resources.filter((r: any) => r.type === 'lxc')) {
list.push({
id: `lxc:${r.vmid}`,
name: r.name || `LXC ${r.vmid}`,
status: r.status,
vmid: r.vmid,
packages: null,
checking: false,
updating: false,
showPackages: false,
error: null,
})
}
}
targets.value = list
} finally {
loadingTargets.value = false
}
}
async function checkTarget(target: Target) {
target.checking = true
target.error = null
try {
const res = await fetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`, {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
target.packages = await res.json() || []
} else {
target.error = 'Erreur lors de la vérification'
target.packages = null
}
} catch {
target.error = 'Erreur réseau'
target.packages = null
} finally {
target.checking = false
}
}
async function checkAll() {
// Séquentiel pour ne pas saturer le SSH pool
for (const target of targets.value) {
if (target.status === 'running') {
await checkTarget(target)
}
}
}
async function updateTarget(target: Target) {
target.updating = true
target.error = null
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: target.id }),
})
if (!res.ok) {
target.updating = false
return
}
const data = await res.json()
connectJobWS(data.job_id, target.name, () => {
target.updating = false
checkTarget(target)
loadHistory()
})
}
async function updateAllTargets() {
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: 'all' }),
})
if (!res.ok) return
const data = await res.json()
// Marquer tous les LXC running comme "updating"
targets.value.forEach(t => { if (t.status === 'running') t.updating = true })
connectJobWS(data.job_id, 'all LXC', () => {
targets.value.forEach(t => { t.updating = false })
loadHistory()
checkAll()
})
}
function connectJobWS(jobId: string, targetName: string, onDone: () => void) {
wsConnection?.close()
activeJob.value = {
jobId,
target: targetName,
output: '',
status: 'running',
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${jobId}?token=${authStore.accessToken}`)
wsConnection.onmessage = async (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'update_output' && msg.payload?.chunk && activeJob.value) {
activeJob.value.output += msg.payload.chunk
await nextTick()
if (terminalEl.value) terminalEl.value.scrollTop = terminalEl.value.scrollHeight
} else if (msg.type === 'update_done') {
if (activeJob.value) activeJob.value.status = 'success'
wsConnection?.close()
onDone()
} else if (msg.type === 'update_error') {
if (activeJob.value) activeJob.value.status = 'error'
wsConnection?.close()
onDone()
}
}
}
async function loadHistory() {
const res = await fetch('/api/updates/history', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) history.value = await res.json() || []
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.updates-page { max-width: 1200px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-md); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.text-xs { font-size: var(--neu-font-xs); }
.header-actions { display: flex; gap: var(--neu-space-sm); flex-shrink: 0; align-items: flex-start; }
.loading-card { text-align: center; padding: var(--neu-space-xl); }
.targets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--neu-space-md);
}
.target-card { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
.target-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-sm); }
.target-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.target-name { font-weight: 600; color: var(--neu-text); font-size: var(--neu-font-md); }
.target-vmid { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
.target-status { min-height: 26px; display: flex; align-items: center; gap: var(--neu-space-xs); }
.not-checked { font-size: var(--neu-font-xs); }
.pkg-count-btn {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.pkg-toggle { font-size: 10px; color: var(--neu-text-muted); }
.package-list {
max-height: 200px;
overflow-y: auto;
padding: var(--neu-space-xs) var(--neu-space-sm);
display: flex;
flex-direction: column;
gap: 2px;
}
.package-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 2px 0;
font-size: 11px;
border-bottom: 1px solid var(--neu-border);
gap: var(--neu-space-sm);
}
.package-item:last-child { border-bottom: none; }
.pkg-name { font-family: monospace; color: var(--neu-text); font-weight: 500; flex-shrink: 0; }
.pkg-versions { font-family: monospace; text-align: right; }
.pkg-versions strong { color: var(--neu-text); }
.target-actions {
display: flex;
gap: var(--neu-space-xs);
margin-top: auto;
padding-top: var(--neu-space-sm);
border-top: 1px solid var(--neu-border);
}
.target-actions .neu-btn { flex: 1; font-size: var(--neu-font-xs); }
.output-card { }
.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-xs); }
.history-item { padding: var(--neu-space-xs) 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); margin-left: auto; }
@media (max-width: 640px) {
.page-header { flex-direction: column; }
.targets-grid { grid-template-columns: 1fr; }
}
</style>

View file

@ -0,0 +1 @@
export { default as Swup } from 'swup'

72
frontend/terminal.html Normal file
View file

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Terminal</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/xterm.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/vendors/xterm.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/terminal.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<span class="sidebar-logo"></span>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()"></button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<span class="sidebar-icon" x-text="item.icon"></span>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.terminal')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()">
<span x-text="theme==='dark'?'☀':'🌙'"></span>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()"></button>
</div>
</nav>
<main id="swup" class="page-content transition-fade terminal-layout">
<div class="terminal-toolbar">
<span id="terminal-status" class="terminal-status">⌛ Connexion…</span>
<button id="terminal-clear" class="neu-btn neu-btn--sm">✕ Effacer</button>
</div>
<div id="terminal-container" class="terminal-container"></div>
</main>
</div>
<style>
[x-cloak]{display:none!important}
.main-layout{display:flex;flex-direction:column;flex:1;margin-left:var(--sidebar-width,240px);transition:margin-left .2s;height:100vh;overflow:hidden}
.terminal-layout{flex:1;display:flex;flex-direction:column;padding:0!important;overflow:hidden}
.terminal-toolbar{display:flex;align-items:center;justify-content:space-between;padding:.5rem 1rem;border-bottom:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0}
.terminal-status{font-size:.8rem;font-family:monospace}
.terminal-status.connected{color:var(--color-success,#22c55e)}
.terminal-status.disconnected,.terminal-status.error{color:var(--color-error,#ef4444)}
.terminal-status.connecting{color:var(--text-muted)}
.terminal-container{flex:1;overflow:hidden;background:#1a1a2e;padding:.5rem}
.terminal-container .xterm{height:100%}
</style>
</body>
</html>

View file

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

View file

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

190
frontend/updates.html Normal file
View file

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Mises à jour</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<span class="sidebar-logo"></span>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()"></button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<span class="sidebar-icon" x-text="item.icon"></span>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.updates')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()">
<span x-text="theme==='dark'?'☀':'🌙'"></span>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()"></button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="updatesPage()" x-cloak>
<!-- Actions globales -->
<div class="page-actions">
<div class="page-actions-left">
<span class="total-badge" x-show="!loading">
<span x-text="totalPackages"></span> paquets à mettre à jour
</span>
</div>
<div class="page-actions-right">
<button class="neu-btn" @click="checkAll()" :disabled="loading || targets.some(t=>t.checking)">
↻ Tout vérifier
</button>
<button class="neu-btn neu-btn--primary" @click="updateAll()"
:disabled="loading || jobStatus === 'running' || totalPackages === 0">
↑ Tout mettre à jour
</button>
</div>
</div>
<!-- Loading -->
<div class="loading-state" x-show="loading">
<div class="spinner-lg"></div>
<span>Chargement des cibles…</span>
</div>
<!-- Target cards -->
<div class="targets-grid" x-show="!loading">
<template x-for="target in targets" :key="target.id">
<div class="neu-card target-card">
<div class="target-header">
<div class="target-info">
<span class="target-name" x-text="target.name"></span>
<span class="target-id" x-text="target.id"></span>
</div>
<div class="target-status">
<span class="badge" :class="target.status" x-text="target.status" x-show="target.status"></span>
</div>
</div>
<!-- Package count -->
<div class="package-summary">
<span x-show="target.checking" class="checking-text">
<span class="spinner-sm"></span> Vérification…
</span>
<span x-show="!target.checking && target.packages === null" class="muted">
Non vérifié
</span>
<span x-show="!target.checking && target.packages !== null && target.packages.length === 0"
class="up-to-date">✓ À jour</span>
<span x-show="!target.checking && target.packages !== null && target.packages.length > 0"
class="has-updates">
<span x-text="target.packages.length"></span> paquet(s) à mettre à jour
</span>
</div>
<!-- Package list (collapsible) -->
<div class="package-list" x-show="target.packages && target.packages.length > 0">
<template x-for="pkg in target.packages" :key="pkg.name">
<div class="package-row">
<span class="pkg-name" x-text="pkg.name"></span>
<span class="pkg-version">
<span class="old-ver" x-text="pkg.old_version"></span>
<span class="arrow"></span>
<span class="new-ver" x-text="pkg.version"></span>
</span>
</div>
</template>
</div>
<!-- Card actions -->
<div class="target-actions">
<button class="neu-btn neu-btn--sm" @click="checkTarget(target)"
:disabled="target.checking">
↻ Vérifier
</button>
<button class="neu-btn neu-btn--sm neu-btn--primary"
@click="updateTarget(target)"
:disabled="target.updating || jobStatus === 'running' || !target.packages || target.packages.length === 0"
x-show="target.status !== 'stopped'">
<span x-show="!target.updating">↑ Mettre à jour</span>
<span x-show="target.updating"><span class="spinner-sm"></span> En cours…</span>
</button>
</div>
</div>
</template>
</div>
<!-- Output streaming -->
<div class="output-panel neu-inset" x-show="currentJob">
<div class="output-header">
<span class="output-title">Job <span x-text="currentJob"></span></span>
<span class="job-status" :class="jobStatus" x-text="jobStatus"></span>
</div>
<pre class="output-content" x-text="output" x-ref="output"></pre>
</div>
</div>
</main>
</div>
<style>
[x-cloak]{display:none!important}
.main-layout{display:flex;flex-direction:column;flex:1;margin-left:var(--sidebar-width,240px);transition:margin-left .2s}
.page-actions{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap}
.page-actions-left,.page-actions-right{display:flex;align-items:center;gap:.75rem}
.total-badge{font-size:.875rem;font-weight:600;color:var(--accent-primary)}
.targets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
.target-card{padding:1rem;display:flex;flex-direction:column;gap:.75rem}
.target-header{display:flex;justify-content:space-between;align-items:flex-start}
.target-info{display:flex;flex-direction:column;gap:.2rem}
.target-name{font-weight:700;font-size:.95rem}
.target-id{font-size:.7rem;color:var(--text-muted);font-family:monospace}
.badge{font-size:.7rem;padding:.2rem .5rem;border-radius:.25rem;text-transform:uppercase;font-weight:600}
.badge.running{background:rgba(34,197,94,.15);color:var(--color-success,#22c55e)}
.badge.stopped{background:rgba(239,68,68,.1);color:var(--color-error,#ef4444)}
.package-summary{font-size:.875rem}
.muted{color:var(--text-muted)}
.up-to-date{color:var(--color-success,#22c55e)}
.has-updates{color:var(--color-warning,#f59e0b);font-weight:600}
.checking-text{display:flex;align-items:center;gap:.4rem;color:var(--text-secondary)}
.package-list{max-height:160px;overflow-y:auto;border-top:1px solid var(--border-color);padding-top:.5rem;display:flex;flex-direction:column;gap:.25rem}
.package-row{display:flex;justify-content:space-between;gap:.5rem;font-size:.75rem}
.pkg-name{font-weight:600;font-family:monospace;color:var(--accent-primary)}
.pkg-version{display:flex;align-items:center;gap:.25rem;color:var(--text-secondary)}
.old-ver{text-decoration:line-through;opacity:.6}
.arrow{color:var(--accent-primary)}
.new-ver{color:var(--color-success,#22c55e);font-weight:600}
.target-actions{display:flex;gap:.5rem;margin-top:auto}
.output-panel{margin-top:1.5rem;border-radius:.75rem;overflow:hidden}
.output-header{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--border-color)}
.output-title{font-weight:600;font-size:.875rem;font-family:monospace}
.job-status{font-size:.75rem;padding:.2rem .5rem;border-radius:.25rem;text-transform:uppercase}
.job-status.running{background:rgba(99,102,241,.15);color:var(--accent-primary)}
.job-status.success{background:rgba(34,197,94,.15);color:var(--color-success,#22c55e)}
.job-status.error{background:rgba(239,68,68,.1);color:var(--color-error,#ef4444)}
.output-content{padding:1rem;font-family:monospace;font-size:.75rem;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto;margin:0;color:var(--text-primary)}
.loading-state{display:flex;align-items:center;gap:.75rem;padding:2rem;color:var(--text-muted)}
.spinner-sm{display:inline-block;width:.875rem;height:.875rem;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin .6s linear infinite}
.spinner-lg{width:2rem;height:2rem;border:3px solid transparent;border-top-color:var(--accent-primary);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</body>
</html>

5
frontend/vendors/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
frontend/vendors/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,48 +0,0 @@
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',
],
},
},
},
},
})

View file

@ -0,0 +1,2 @@
export { Terminal } from '@xterm/xterm'
export { FitAddon } from '@xterm/addon-fit'