/** * 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 } }) })