core/frontend/js/app.js
enzo 91cf788221 refactor: sidebar nav items modules dynamiques selon is_enabled
Les items CORE (dashboard, proxmox, updates, settings, modules) sont
toujours affichés. Les modules optionnels (terminal, files, services,
logs) n'apparaissent dans la sidebar que si leur module est activé
en base de données — conformément à l'instruction.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:55:39 +01:00

1356 lines
45 KiB
JavaScript

/**
* 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 = `
<template x-for="t in $store.toasts.items" :key="t.id">
<div class="toast" :class="'toast--' + t.type" x-transition.opacity>
<i :class="{
'lnid-cross-circle': t.type === 'error',
'lnid-check-circle-1': t.type === 'success',
'lnid-warning-triangle-1':t.type === 'warn',
'lnid-information-circle':t.type === 'info'
}"></i>
<span class="toast-msg" x-text="t.message"></span>
<button class="toast-close" @click="$store.toasts.remove(t.id)">
<i class="lnid-cross"></i>
</button>
</div>
</template>
`
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.
// Items modules : visibles seulement si le module est activé en DB.
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' },
]
// Définition des items de navigation pour les modules optionnels.
// Un module dont l'id n'est pas ici n'aura pas d'entrée dans la sidebar.
const _moduleNavDef = {
terminal: { iconClass: 'lnid-terminal', iconColor: '#a78bfa', labelKey: 'nav.terminal', href: '/terminal.html' },
files: { iconClass: 'lnid-folder-1', iconColor: '#84cc16', labelKey: 'nav.files', href: '/files.html' },
services: { iconClass: 'lnid-gear-2', iconColor: '#fb923c', labelKey: 'nav.services', href: '/services.html' },
logs: { iconClass: 'lnid-scroll-angular-1', iconColor: '#38bdf8', labelKey: 'nav.logs', href: '/logs.html' },
}
Alpine.data('sidebar', () => ({
get collapsed() { return Alpine.store('ui').sidebarCollapsed },
get currentPage() { return Alpine.store('ui').currentPage },
// Commence avec les items CORE ; init() ajoute les modules activés
navItems: [..._coreNavItems],
async init() {
try {
const res = await apiFetch('/api/modules')
if (!res.ok) return
const modules = await res.json() || []
const moduleItems = modules
.filter(m => m.is_enabled && !m.is_core && _moduleNavDef[m.id])
.map(m => ({ id: m.id, ..._moduleNavDef[m.id] }))
// Insérer les modules entre Updates et Settings
const insertAt = this.navItems.findIndex(i => i.id === 'settings')
this.navItems = [
...this.navItems.slice(0, insertAt),
...moduleItems,
...this.navItems.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: {},
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) },
}))
// ── Composant: servicePage ──────────────────────────────────────────────
Alpine.data('servicePage', () => ({
services: [],
loading: false,
target: 'host',
targets: [{ value: 'host', label: 'Host Proxmox' }],
filter: '',
actioning: {},
get filtered() {
const q = this.filter.toLowerCase()
if (!q) return this.services
return this.services.filter(s =>
s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
)
},
async init() {
await this.loadTargets()
await this.load()
},
async loadTargets() {
try {
const res = await apiFetch('/api/proxmox/lxc')
if (res.ok) {
const lxc = await res.json() || []
this.targets = [
{ value: 'host', label: 'Host Proxmox' },
...lxc.map(c => ({ value: `lxc:${c.vmid}`, label: `LXC ${c.vmid}${c.name || 'CT'+c.vmid}` }))
]
}
} catch(e) {}
},
async load() {
this.loading = true
try {
const res = await apiFetch(`/api/services?target=${this.target}`)
if (res.ok) this.services = await res.json() || []
} finally {
this.loading = false
}
},
async action(name, act) {
this.actioning[name] = act
try {
const res = await apiFetch(`/api/services/${name}/${act}`, {
method: 'POST',
body: JSON.stringify({ target: this.target })
})
if (res.ok) {
Alpine.store('toasts').success(`${act} ${name}`)
await this.load()
} else {
const b = await res.json().catch(() => ({}))
Alpine.store('toasts').error(b.error || `Erreur ${act}`)
}
} catch(e) {
Alpine.store('toasts').error(e.message)
} finally {
this.actioning[name] = false
}
},
stateClass(svc) {
if (svc.active_state === 'active') return 'state-active'
if (svc.active_state === 'failed') return 'state-failed'
if (svc.active_state === 'inactive') return 'state-inactive'
return 'state-other'
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: logsPage ─────────────────────────────────────────────────
Alpine.data('logsPage', () => ({
lines: [],
target: 'host',
targets: [{ value: 'host', label: 'Host Proxmox' }],
unit: '',
units: [],
linesCount: '100',
following: false,
ws: null,
async init() {
await this.loadTargets()
await this.loadUnits()
},
async loadTargets() {
try {
const res = await apiFetch('/api/proxmox/lxc')
if (res.ok) {
const lxc = await res.json() || []
this.targets = [
{ value: 'host', label: 'Host Proxmox' },
...lxc.map(c => ({ value: `lxc:${c.vmid}`, label: `LXC ${c.vmid}${c.name || 'CT'+c.vmid}` }))
]
}
} catch(e) {}
},
async loadUnits() {
try {
const res = await apiFetch(`/api/logs/units?target=${this.target}`)
if (res.ok) this.units = await res.json() || []
} catch(e) {}
},
async onTargetChange() {
this.stopFollow()
this.unit = ''
await this.loadUnits()
},
toggleFollow() {
if (this.following) {
this.stopFollow()
} else {
this.startFollow()
}
},
startFollow() {
this.lines = []
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const unit = this.unit ? `&unit=${encodeURIComponent(this.unit)}` : ''
const url = `${proto}://${location.host}/ws/logs?token=${token}&target=${this.target}&lines=${this.linesCount}${unit}`
this.ws = new WebSocket(url)
this.following = true
this.ws.onmessage = (e) => {
const incoming = e.data.split('\n').filter(l => l !== '')
this.lines.push(...incoming)
if (this.lines.length > 3000) this.lines = this.lines.slice(-3000)
this.$nextTick(() => {
const el = this.$refs.logOutput
if (el) el.scrollTop = el.scrollHeight
})
}
this.ws.onclose = () => { this.following = false; this.ws = null }
this.ws.onerror = () => { this.following = false; this.ws = null }
},
stopFollow() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.following = false
},
clearLog() {
this.lines = []
},
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 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
}
})
})