core/frontend/js/app.js
enzo 9739dbaee8 Correction CSS Swup, types WS et création fichier de suivi
- Extraction de tous les styles inline en css/pages.css (chargé globalement)
  pour corriger le CSS cassé lors des navigations Swup
- Correction types WebSocket : proxmox_resources → resources_update
  et msg.data → msg.payload (format réel du hub Go)
- Ajout d'un fetch HTTP immédiat dans dashboardPage/proxmoxPage
  pour éviter l'attente du premier tick (10s) du polling WS
- Correction msg.payload pour les updates (update_output/done/error)
- Ajout class terminal-wrapper sur .main-layout de terminal.html
  pour le fullscreen height sans affecter les autres pages
- Création SUIVI.md : état d'implémentation vs instruction.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:49:33 +01:00

747 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
// Le backend retourne "access_token" (pas "token")
this.token = data.access_token
localStorage.setItem('pxp_token', data.access_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()
// Le backend retourne "access_token" (pas "token")
this.token = data.access_token
this.user = data.user
localStorage.setItem('pxp_token', data.access_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',
async init() {
// Chargement immédiat via HTTP, puis WS pour le temps réel
await this.fetchResources()
this.connectWS()
},
destroy() {
if (this.ws) this.ws.close()
},
async fetchResources() {
try {
const res = await apiFetch('/api/proxmox/resources')
if (res.ok) {
this.resources = await res.json() || []
this.wsStatus = 'ok'
}
} catch (e) { /* WS prendra le relais */ }
},
connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
// Le backend publie type="resources_update", payload=[...]
if (msg.type === 'resources_update') {
this.resources = msg.payload || []
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: {},
async init() {
await this.fetchResources()
this.connectWS()
},
destroy() { if (this.ws) this.ws.close() },
async fetchResources() {
try {
const res = await apiFetch('/api/proxmox/resources')
if (res.ok) {
this.resources = await res.json() || []
this.wsStatus = 'ok'
}
} catch (e) { /* WS prendra le relais */ }
},
connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
// Le backend publie type="resources_update", payload=[...]
if (msg.type === 'resources_update') {
this.resources = msg.payload || []
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(--neu-danger)'
if (pct > 50) return 'var(--neu-warning)'
return 'var(--neu-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)
// Le backend publie dans msg.payload (pas msg.data)
if (msg.type === 'update_output') {
this.output += msg.payload?.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.payload?.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: '', // chiffré, laisser vide = pas de changement
proxmox_url: '',
proxmox_token: '', // chiffré, format: user@realm!tokenid=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 {
// Backend: PUT /api/settings/{key} avec { value: "..." } — un appel par clé
const keys = Object.keys(this.settings)
for (const key of keys) {
const val = this.settings[key]
// Ignorer les champs vides pour les secrets (ne pas écraser l'existant)
if (val === '' && (key === 'ssh_password' || key === 'proxmox_token')) continue
const res = await apiFetch(`/api/settings/${key}`, {
method: 'PUT',
body: JSON.stringify({ value: val }),
})
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.error || `Erreur sauvegarde de ${key}`)
}
}
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 {
// Backend: is_enabled (pas enabled)
const action = mod.is_enabled ? 'disable' : 'enable'
const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' })
if (res.ok) {
mod.is_enabled = !mod.is_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()
const publicPages = ['login', 'install', 'index', '']
const currentPage = window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
// Guard rapide (synchrone) : si pas de token du tout, redirect immédiat
if (!publicPages.includes(currentPage) && !localStorage.getItem('pxp_token')) {
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
}
})
})