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:
parent
7ba0ff143c
commit
2098c80ec1
48 changed files with 2446 additions and 5317 deletions
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
|
@ -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
92
frontend/build.mjs
Normal 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/')
|
||||
|
|
@ -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
158
frontend/dashboard.html
Normal 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>
|
||||
|
|
@ -3,13 +3,16 @@
|
|||
<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" />
|
||||
<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>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<!-- 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
175
frontend/install.html
Normal 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
710
frontend/js/app.js
Normal 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
110
frontend/js/terminal.js
Normal 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
131
frontend/login.html
Normal 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
109
frontend/modules.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1714
frontend/package-lock.json
generated
1714
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
158
frontend/proxmox.html
Normal 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
163
frontend/settings.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
1
frontend/swup-bundle.entry.mjs
Normal file
1
frontend/swup-bundle.entry.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Swup } from 'swup'
|
||||
72
frontend/terminal.html
Normal file
72
frontend/terminal.html
Normal 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>
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -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
190
frontend/updates.html
Normal 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
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
1
frontend/vendors/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
2
frontend/xterm-bundle.entry.mjs
Normal file
2
frontend/xterm-bundle.entry.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Terminal } from '@xterm/xterm'
|
||||
export { FitAddon } from '@xterm/addon-fit'
|
||||
Loading…
Add table
Add a link
Reference in a new issue