- auth store fetchMe(): sync theme/sidebar_position/lang from DB to localStorage+stores on login/refresh - profilePage setters: PATCH /api/auth/preferences on every preference change - updatesPage: add history tab (GET /api/updates/history) with job list, click to view output - dashboardPage: load shortcuts from settings API, fall back to defaults if none configured - settingsPage: new Raccourcis tab to add/remove/configure dashboard shortcuts (saved as JSON) - settings.go: expose dashboard_shortcuts in publicSettings for GET/PUT access - pages.css: add .history-table, .shortcut-row styles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1077 lines
35 KiB
JavaScript
1077 lines
35 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 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 {
|
|
// Token expiré, invalide, ou toute autre erreur → tenter un refresh
|
|
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', 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: 'terminal', iconClass: 'lnid-terminal', iconColor: '#a78bfa', labelKey: 'nav.terminal', href: '/terminal.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' },
|
|
],
|
|
|
|
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,
|
|
|
|
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 = { ...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)
|
|
} catch (e) { /* ignore */ }
|
|
this.revoking = { ...this.revoking, [id]: 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' })
|
|
},
|
|
|
|
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 {
|
|
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: '',
|
|
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) },
|
|
}))
|
|
|
|
}) // 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
|
|
}
|
|
})
|
|
})
|