core/frontend/js/app.js
enzo 5f6681dd17 feat: LineIcons Duotone, page profil, widgets dashboard, sidebar gauche/droite
- Intégration LineIcons Duotone (css/ + toutes les pages)
- Remplacement de tous les symboles Unicode par des icônes lnid-*
- Page profile.html : préférences thème, position sidebar, langue
- Dashboard : système de widgets add/remove + drag-and-drop natif
- Sidebar gauche/droite configurable per-user (data-sidebar CSS + FOUC script)
- Store ui : sidebarPosition, applySidebarPosition(), setSidebarPosition()
- Composant profilePage() dans app.js
- nav.profile ajouté dans fr.json et en.json
- SUIVI.md mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:38:48 +01:00

822 lines
26 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.
*/
// ── 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',
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 ──────────────────────────────────────────────────
Alpine.data('sidebar', () => ({
get collapsed() { return Alpine.store('ui').sidebarCollapsed },
get currentPage() { return Alpine.store('ui').currentPage },
navItems: [
{ id: 'dashboard', iconClass: 'lnid-dashboard-square-1', labelKey: 'nav.dashboard', href: '/dashboard.html' },
{ id: 'proxmox', iconClass: 'lnid-server-1', labelKey: 'nav.proxmox', href: '/proxmox.html' },
{ id: 'updates', iconClass: 'lnid-arrow-upward', labelKey: 'nav.updates', href: '/updates.html' },
{ id: 'terminal', iconClass: 'lnid-terminal', labelKey: 'nav.terminal', href: '/terminal.html' },
{ id: 'settings', iconClass: 'lnid-gear-1', labelKey: 'nav.settings', href: '/settings.html' },
{ id: 'modules', iconClass: 'lnid-puzzle', 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',
configOpen: false,
dragSrcIdx: null,
widgets: (function() {
try {
return JSON.parse(localStorage.getItem('pxp_widgets') || 'null') || [
{ id: 'status', visible: true, label: 'Statut LXC' },
{ id: 'lxc-list', visible: true, label: 'Liste LXC' },
{ id: 'links', visible: false, label: 'Raccourcis' },
]
} catch(e) {
return [
{ id: 'status', visible: true, label: 'Statut LXC' },
{ id: 'lxc-list', visible: true, label: 'Liste LXC' },
{ id: 'links', visible: false, label: 'Raccourcis' },
]
}
})(),
saveWidgets() { localStorage.setItem('pxp_widgets', JSON.stringify(this.widgets)) },
onDragStart(idx) { this.dragSrcIdx = idx },
onDrop(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 = null
this.saveWidgets()
},
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 = 'connected'
}
} 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)
if (msg.type === 'resources_update') {
this.resources = msg.payload || []
this.wsStatus = 'connected'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
},
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') },
// compat
get runningCount() { return this.running.length },
get stoppedCount() { return this.stopped.length },
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: '',
init() {
this.theme = Alpine.store('ui').theme
this.sidebarPosition = Alpine.store('ui').sidebarPosition
this.lang = Alpine.store('i18n').lang
},
setTheme(t) {
this.theme = t
Alpine.store('ui').theme = t
Alpine.store('ui').applyTheme()
localStorage.setItem('pxp_theme', t)
},
setSidebarPosition(pos) {
this.sidebarPosition = pos
Alpine.store('ui').setSidebarPosition(pos)
},
async setLang(lang) {
this.lang = lang
await Alpine.store('i18n').setLang(lang)
},
get user() { return Alpine.store('auth').user },
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
}
})
})