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>
1356 lines
45 KiB
JavaScript
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
|
|
}
|
|
})
|
|
})
|