/** * 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. */ // ── WsProxy — WebSocket via Service Worker avec fallback direct ───────────── const WsProxy = { _subs: new Map(), // channel → [{onMessage, onStatus}] _direct: new Map(), // channel → WebSocket (fallback sans SW) init() { if (!('serviceWorker' in navigator)) return navigator.serviceWorker.register('/ws.sw.js') .catch(e => console.warn('[WsProxy] SW registration failed:', e)) navigator.serviceWorker.addEventListener('message', event => { const { channel, type, data, status } = event.data || {} if (!channel) return const subs = this._subs.get(channel) || [] if (type === 'WS_MESSAGE') subs.forEach(s => s.onMessage?.(data)) if (type === 'WS_STATUS') subs.forEach(s => s.onStatus?.(status)) }) }, async subscribe(channel, url, onMessage, onStatus) { if (!this._subs.has(channel)) this._subs.set(channel, []) const sub = { onMessage, onStatus } this._subs.get(channel).push(sub) if ('serviceWorker' in navigator) { try { await navigator.serviceWorker.ready const sw = navigator.serviceWorker.controller if (sw) { sw.postMessage({ type: 'WS_SUBSCRIBE', channel, url }) return () => this._unsub(channel, sub, true) } } catch(e) { /* fallback */ } } this._connectDirect(channel, url) return () => this._unsub(channel, sub, false) }, _unsub(channel, sub, usedSW) { const subs = this._subs.get(channel) || [] const idx = subs.indexOf(sub) if (idx > -1) subs.splice(idx, 1) if (usedSW) { navigator.serviceWorker?.controller?.postMessage({ type: 'WS_UNSUBSCRIBE', channel }) } else if (subs.length === 0) { this._direct.get(channel)?.close() this._direct.delete(channel) } }, _connectDirect(channel, url) { const ex = this._direct.get(channel) if (ex && ex.readyState < 2) return const ws = new WebSocket(url) this._direct.set(channel, ws) const fire = (cb, arg) => (this._subs.get(channel) || []).forEach(s => s[cb]?.(arg)) ws.onopen = () => fire('onStatus', 'connected') ws.onmessage = (e) => fire('onMessage', e.data) ws.onerror = () => fire('onStatus', 'error') ws.onclose = () => { fire('onStatus', 'disconnected') setTimeout(() => { if ((this._subs.get(channel) || []).length > 0) this._connectDirect(channel, url) }, 3000) } }, send(channel, data) { const sw = navigator.serviceWorker?.controller if (sw) sw.postMessage({ type: 'WS_SEND', channel, payload: data }) else this._direct.get(channel)?.send(data) }, } // ── 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 toasts ──────────────────────────────────────────────────────── Alpine.store('toasts', { items: [], _id: 0, add(message, type = 'info', duration = 5000) { const id = ++this._id this.items.push({ id, message, type }) if (duration > 0) setTimeout(() => this.remove(id), duration) return id }, remove(id) { this.items = this.items.filter(t => t.id !== id) }, error(msg) { return this.add(msg, 'error', 8000) }, success(msg) { return this.add(msg, 'success', 4000) }, warn(msg) { return this.add(msg, 'warn', 6000) }, info(msg) { return this.add(msg, 'info', 4000) }, }) // Injecter le conteneur de toasts dans le body (persiste à travers Swup) ;(function() { const tc = document.createElement('div') tc.id = 'pxp-toasts' tc.setAttribute('x-data', '') tc.innerHTML = ` ` document.body.appendChild(tc) })() // ── 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() // Sync préférences DB → localStorage + stores const u = this.user if (u.theme && u.theme !== Alpine.store('ui').theme) { localStorage.setItem('pxp_theme', u.theme) Alpine.store('ui').theme = u.theme Alpine.store('ui').applyTheme() } if (u.sidebar_position && u.sidebar_position !== Alpine.store('ui').sidebarPosition) { Alpine.store('ui').setSidebarPosition(u.sidebar_position) } if (u.lang && u.lang !== Alpine.store('i18n').lang) { Alpine.store('i18n').setLang(u.lang) } } else if (res.status === 401) { // Token expiré → tenter un refresh silencieusement await this.tryRefresh() } else { // Erreur inattendue (404, 500…) — signaler + tenter quand même const body = await res.json().catch(() => ({})) console.error(`[auth/me] HTTP ${res.status}`, body.error || '') Alpine.store('toasts').error( `Erreur ${res.status} sur /api/auth/me : ${body.error || 'voir console'}` ) 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.access_token localStorage.setItem('pxp_token', data.access_token) await this.fetchMe() } else { // Lire le vrai message d'erreur du backend pour le diagnostic const body = await res.json().catch(() => ({})) const serverMsg = body.error || `HTTP ${res.status}` const page = window.location.pathname.replace(/^\/|\.html$/g, '') if (page !== 'login' && page !== 'install' && page !== 'index' && page !== '') { sessionStorage.setItem('pxp_auth_notice', `Refresh échoué : ${serverMsg}`) } console.error('[auth/tryRefresh]', res.status, serverMsg) 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() sessionStorage.setItem('pxp_auth_notice', 'Déconnexion réussie') 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', sidebarPosition: localStorage.getItem('pxp_sidebar_pos') || 'left', currentPage: '', init() { this.applyTheme() this.applySidebarPosition() 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() }, applySidebarPosition() { document.documentElement.setAttribute('data-sidebar', this.sidebarPosition) }, setSidebarPosition(pos) { this.sidebarPosition = pos localStorage.setItem('pxp_sidebar_pos', pos) this.applySidebarPosition() }, 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 ────────────────────────────────────────────────── // Items CORE toujours visibles (sidebar hardcodée pour le CORE) const _coreNavItems = [ { id: 'dashboard', iconClass: 'lnid-dashboard-square-1', iconColor: '#6c8ef4', labelKey: 'nav.dashboard', href: '/dashboard.html' }, { id: 'proxmox', iconClass: 'lnid-server-1', iconColor: '#22c55e', labelKey: 'nav.proxmox', href: '/proxmox.html' }, { id: 'updates', iconClass: 'lnid-arrow-upward', iconColor: '#f59e0b', labelKey: 'nav.updates', href: '/updates.html' }, { id: 'settings', iconClass: 'lnid-gear-1', iconColor: '#94a3b8', labelKey: 'nav.settings', href: '/settings.html' }, { id: 'modules', iconClass: 'lnid-puzzle', iconColor: '#f472b6', labelKey: 'nav.modules', href: '/modules.html' }, ] Alpine.data('sidebar', () => ({ get collapsed() { return Alpine.store('ui').sidebarCollapsed }, get currentPage() { return Alpine.store('ui').currentPage }, navItems: [..._coreNavItems], async init() { await this.refreshNav() }, async refreshNav() { try { const res = await apiFetch('/api/modules') if (!res.ok) return const allModules = await res.json() || [] // Modules optionnels activés avec nav_href défini const moduleItems = allModules .filter(m => !m.is_core && m.is_enabled && m.nav_href) .map(m => ({ id: m.id, iconClass: m.nav_icon || 'lnid-puzzle', iconColor: m.nav_color || '#94a3b8', labelKey: m.nav_label_key || `nav.${m.id}`, href: m.nav_href, })) // Insérer entre Updates et Settings const insertAt = _coreNavItems.findIndex(i => i.id === 'settings') this.navItems = [ ..._coreNavItems.slice(0, insertAt), ...moduleItems, ..._coreNavItems.slice(insertAt), ] } catch(e) {} }, iconStyle(item) { return this.isActive(item.id) ? '' : `color: ${item.iconColor}` }, 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 }, set lang(v) { /* x-model a besoin d'un setter ; @change gère la vraie MAJ */ }, 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, init() { // Afficher le message si la session a expiré avant la redirection const msg = sessionStorage.getItem('pxp_auth_notice') if (msg) { sessionStorage.removeItem('pxp_auth_notice') setTimeout(() => Alpine.store('toasts').warn(msg), 200) } }, 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: [], _unsubWS: null, wsStatus: 'connecting', editMode: false, dragSrcIdx: null, _dragOriginal: null, hoveredWidgetId: null, shortcuts: [], widgets: (function() { const defaults = [ { id: 'status', visible: true, size: 1, label: 'Statut LXC' }, { id: 'lxc-list', visible: true, size: 1, label: 'Liste LXC' }, { id: 'links', visible: false, size: 1, label: 'Raccourcis' }, ] try { const saved = JSON.parse(localStorage.getItem('pxp_widgets') || 'null') if (!saved) return defaults const ids = saved.map(w => w.id) return [...saved, ...defaults.filter(d => !ids.includes(d.id))] } catch(e) { return defaults } })(), saveWidgets() { localStorage.setItem('pxp_widgets', JSON.stringify(this.widgets)) }, // ── Edition toggleEdit() { this.editMode = !this.editMode }, showWidget(id) { const w = this.widgets.find(w => w.id === id) if (w) { w.visible = true; this.saveWidgets() } }, hideWidget(w) { w.visible = false this.saveWidgets() }, // ── Resize via souris (bords de la tuile) startResize(event, w) { event.stopPropagation() event.preventDefault() const startX = event.clientX const origSize = w.size || 1 const onMove = (e) => { const grid = this.$el.querySelector('.widgets-grid') const col = grid ? (grid.offsetWidth / 2) : 400 const dx = e.clientX - startX if (origSize === 1 && dx > col * 0.3) w.size = 2 else if (origSize === 2 && dx < -col * 0.3) w.size = 1 else w.size = origSize } const onUp = () => { this.saveWidgets() document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', onUp) } document.addEventListener('mousemove', onMove) document.addEventListener('mouseup', onUp) }, // ── DnD — réorganisation live (les autres widgets se déplacent pendant le drag) onDragStart(idx) { this.dragSrcIdx = idx this._dragOriginal = JSON.parse(JSON.stringify(this.widgets)) }, onDragEnter(idx) { if (this.dragSrcIdx === null || this.dragSrcIdx === idx) return const moved = this.widgets.splice(this.dragSrcIdx, 1)[0] this.widgets.splice(idx, 0, moved) this.dragSrcIdx = idx }, onDragEnd() { // Annulation (pas de drop) → restaurer l'ordre original if (this._dragOriginal) { this.widgets = JSON.parse(JSON.stringify(this._dragOriginal)) this._dragOriginal = null } this.dragSrcIdx = null }, onDrop() { this._dragOriginal = null // commit — ne pas restaurer dans onDragEnd this.dragSrcIdx = null this.saveWidgets() }, // ── Data async init() { await this.fetchResources() await this.connectWS() await this.loadShortcuts() }, destroy() { if (this._unsubWS) { this._unsubWS(); this._unsubWS = null } }, async loadShortcuts() { try { const res = await apiFetch('/api/settings') if (res.ok) { const data = await res.json() if (data.dashboard_shortcuts) { const parsed = JSON.parse(data.dashboard_shortcuts) if (Array.isArray(parsed) && parsed.length > 0) this.shortcuts = parsed } } } catch(e) { /* use defaults */ } }, get displayShortcuts() { return this.shortcuts.length ? this.shortcuts : [ { href: '/proxmox.html', icon: 'lnid-server-1', label: 'Proxmox' }, { href: '/terminal.html', icon: 'lnid-terminal', label: 'Terminal' }, { href: '/updates.html', icon: 'lnid-arrow-upward', label: 'Updates' }, ] }, async fetchResources() { try { const res = await apiFetch('/api/proxmox/resources') if (res.ok) { this.resources = await res.json() || []; this.wsStatus = 'connected' } } catch (e) { /* WS prendra le relais */ } }, async connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws' const token = encodeURIComponent(localStorage.getItem('pxp_token') || '') const url = `${proto}://${location.host}/ws/proxmox?token=${token}` this._unsubWS = await WsProxy.subscribe( 'proxmox', url, (data) => { const msg = JSON.parse(data) if (msg.type === 'resources_update') { this.resources = msg.payload || []; this.wsStatus = 'connected' } }, (status) => { this.wsStatus = status } ) }, get visibleWidgets() { return this.widgets.filter(w => w.visible) }, get lxc() { return this.resources.filter(r => r.type === 'lxc') }, get running() { return this.lxc.filter(r => r.status === 'running') }, get stopped() { return this.lxc.filter(r => r.status !== 'running') }, get lxcList() { return this.lxc }, get vmList() { return this.resources.filter(r => r.type === 'qemu') }, t(key) { return Alpine.store('i18n').t(key) }, })) // ── Composant: profilePage ────────────────────────────────────────────── Alpine.data('profilePage', () => ({ theme: '', sidebarPosition: '', lang: '', sessions: [], sessionsLoading: true, revoking: {}, async init() { this.theme = Alpine.store('ui').theme this.sidebarPosition = Alpine.store('ui').sidebarPosition this.lang = Alpine.store('i18n').lang await this.loadSessions() }, setTheme(t) { this.theme = t Alpine.store('ui').theme = t Alpine.store('ui').applyTheme() localStorage.setItem('pxp_theme', t) apiFetch('/api/auth/preferences', { method: 'PATCH', body: JSON.stringify({ theme: t }) }).catch(() => {}) }, setSidebarPosition(pos) { this.sidebarPosition = pos Alpine.store('ui').setSidebarPosition(pos) apiFetch('/api/auth/preferences', { method: 'PATCH', body: JSON.stringify({ sidebar_position: pos }) }).catch(() => {}) }, async setLang(lang) { this.lang = lang await Alpine.store('i18n').setLang(lang) apiFetch('/api/auth/preferences', { method: 'PATCH', body: JSON.stringify({ lang }) }).catch(() => {}) }, async loadSessions() { this.sessionsLoading = true try { const res = await apiFetch('/api/auth/sessions') if (res.ok) this.sessions = await res.json() } catch (e) { /* ignore */ } this.sessionsLoading = false }, async revokeSession(id) { this.revoking[id] = true try { const res = await apiFetch(`/api/auth/sessions/${id}`, { method: 'DELETE' }) if (res.ok) { this.sessions = this.sessions.filter(s => s.id !== id) Alpine.store('toasts').success('Session révoquée') } else { const d = await res.json().catch(() => ({})) Alpine.store('toasts').error(d.error || 'Erreur révocation session') } } catch (e) { Alpine.store('toasts').error(`Erreur réseau — ${e.message}`) } finally { this.revoking[id] = false } }, isRevoking(id) { return this.revoking[id] === true }, formatDate(raw) { if (!raw) return '—' // SQLite retourne "YYYY-MM-DD HH:MM:SS" ou RFC3339 selon le driver // new Date() gère les deux si on normalise le séparateur const d = new Date(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z') if (isNaN(d.getTime())) return raw return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }, parseUA(ua) { if (!ua) return 'Navigateur inconnu' if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${RegExp.$1}` if (/Chrome\/(\d+)/i.test(ua) && !/Chromium|Edge|OPR/i.test(ua)) return `Chrome ${RegExp.$1}` if (/Edg\/(\d+)/i.test(ua)) return `Edge ${RegExp.$1}` if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) return 'Safari' if (/OPR\/(\d+)/i.test(ua)) return `Opera ${RegExp.$1}` return ua.slice(0, 40) }, get user() { return Alpine.store('auth').user }, t(key) { return Alpine.store('i18n').t(key) }, })) // ── Composant: proxmoxPage ────────────────────────────────────────────── Alpine.data('proxmoxPage', () => ({ resources: [], _unsubWS: null, wsStatus: 'connecting', actionLoading: {}, async init() { await this.fetchResources() await this.connectWS() }, destroy() { if (this._unsubWS) { this._unsubWS(); this._unsubWS = null } }, async fetchResources() { try { const res = await apiFetch('/api/proxmox/resources') if (res.ok) { this.resources = await res.json() || [] this.wsStatus = 'connected' } } catch (e) { /* WS prendra le relais */ } }, async connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws' const token = encodeURIComponent(localStorage.getItem('pxp_token') || '') const url = `${proto}://${location.host}/ws/proxmox?token=${token}` this._unsubWS = await WsProxy.subscribe( 'proxmox', url, (data) => { const msg = JSON.parse(data) if (msg.type === 'resources_update') { this.resources = msg.payload || []; this.wsStatus = 'connected' } }, (status) => { this.wsStatus = status } ) }, async action(vmid, type, action) { const key = `${vmid}-${action}` this.actionLoading[key] = true try { const res = await apiFetch(`/api/proxmox/${type}/${vmid}/${action}`, { method: 'POST' }) if (res.ok) { Alpine.store('toasts').success(`Action « ${action} » envoyée sur ${type} ${vmid}`) } else { const d = await res.json().catch(() => ({})) Alpine.store('toasts').error(d.error || `Erreur action ${action} (HTTP ${res.status})`) } } catch(e) { Alpine.store('toasts').error(`Erreur réseau — ${e.message}`) } 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: '', activeTab: 'targets', history: [], historyLoading: false, 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) { if (target.status !== 'running') return // container arrêté → pas de SSH possible 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() } else { target.packages = [] } } catch(e) { console.error('checkTarget', e) target.packages = [] } 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() } } }) }, async loadHistory() { this.historyLoading = true try { const res = await apiFetch('/api/updates/history') if (res.ok) this.history = await res.json() || [] } catch(e) { /* ignore */ } this.historyLoading = false }, formatDate(iso) { if (!iso) return '—' const d = new Date(iso) return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }, 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: '', shortcuts: [], 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) if (data.dashboard_shortcuts) { try { this.shortcuts = JSON.parse(data.dashboard_shortcuts) } catch(e) { this.shortcuts = [] } } } } 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 } }, addShortcut() { this.shortcuts.push({ label: '', href: '', icon: 'lnid-link-1' }) }, removeShortcut(idx) { this.shortcuts.splice(idx, 1) }, async saveShortcuts() { this.saving = true this.saved = false this.error = '' try { const res = await apiFetch('/api/settings/dashboard_shortcuts', { method: 'PUT', body: JSON.stringify({ value: JSON.stringify(this.shortcuts) }), }) if (!res.ok) { const d = await res.json().catch(() => ({})) throw new Error(d.error || 'Erreur sauvegarde raccourcis') } 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: {}, storeModules: [], storeLoading: true, storeError: '', installing: {}, rebuilding: false, rebuildDone: false, _pollTimer: null, async init() { await Promise.all([this.load(), this.loadStore()]) }, destroy() { if (this._pollTimer) clearInterval(this._pollTimer) }, 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 loadStore() { this.storeLoading = true this.storeError = '' try { const res = await apiFetch('/api/registry/modules') if (res.ok) { const data = await res.json() this.storeModules = data.modules || [] if (data.error) this.storeError = data.error } else { this.storeError = `Erreur serveur (HTTP ${res.status})` this.storeModules = [] } } catch(e) { this.storeError = 'Impossible de joindre le store : ' + e.message this.storeModules = [] } finally { this.storeLoading = false } }, async toggle(mod) { this.toggling[mod.id] = true try { const action = mod.is_enabled ? 'disable' : 'enable' const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' }) if (res.ok) { const data = await res.json().catch(() => ({})) mod.is_enabled = !mod.is_enabled if (data.restarting) { Alpine.store('toasts').info('Redémarrage du container en cours…') this._startRebuildPoll() } // Rafraîchir la sidebar const sb = document.querySelector('[x-data="sidebar()"]') if (sb) { const sidebarData = Alpine.$data(sb) if (sidebarData?.refreshNav) await sidebarData.refreshNav() } } } catch(e) { console.error(e) } finally { this.toggling[mod.id] = false } }, async install(mod) { this.installing[mod.id] = true try { const res = await apiFetch(`/api/registry/modules/${mod.id}/install`, { method: 'POST' }) if (res.ok) { const data = await res.json().catch(() => ({})) mod.installed = true if (data.rebuilding) { this.rebuilding = true Alpine.store('toasts').info(`Module ${mod.id} installé — rebuild en cours (~2 min)`) this._startRebuildPoll() } else { Alpine.store('toasts').success(`Module ${mod.id} installé`) } await this.load() } else { const b = await res.json().catch(() => ({})) Alpine.store('toasts').error(b.error || 'Erreur installation') } } catch(e) { Alpine.store('toasts').error(e.message) } finally { this.installing[mod.id] = false } }, // Poll /api/health toutes les 3s pour détecter le retour du container après rebuild/restart. _startRebuildPoll() { if (this._pollTimer) return // Attendre 5s avant de commencer à poller (le container est encore en train de s'arrêter) setTimeout(() => { this._pollTimer = setInterval(async () => { try { const res = await fetch('/api/health') if (res.ok) { clearInterval(this._pollTimer) this._pollTimer = null this.rebuilding = false this.rebuildDone = true } } catch(_) { /* container encore hors ligne, on attend */ } }, 3000) }, 5000) }, t(key) { return Alpine.store('i18n').t(key) }, })) // Note: servicePage et logsPage ont été déplacés dans les modules // indépendants viewServices et viewLogs. }) // 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 auth : si pas authentifié (token absent ou invalid/expiré), redirect login if (!publicPages.includes(currentPage) && !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 } }) })