feat(frontend): Service Worker WS, mode édition dashboard, sidebar click-to-toggle

- WebSocket via Service Worker (ws.sw.js) : connexions persistantes entre navigations
  Swup, reconnexion exponentielle, protocole WS_SUBSCRIBE/WS_UNSUBSCRIBE/WS_SEND
- WsProxy dans app.js : abstraction SW + fallback WebSocket direct
- proxmoxPage migré vers WsProxy (identique au dashboardPage)
- Dashboard : mode édition toggle — DnD, resize (x1/x2), masquer/afficher widget
  uniquement actifs en mode édition ; preview drag (is-dragging/drag-over)
- Sidebar : suppression bouton hamburger, clic sur sidebar-header pour replier
- pages.css : targets-grid 350px, styles edit mode, widget-size-2, drag preview
- neu.css : sidebar-header cursor pointer, suppression .sidebar-toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 19:09:58 +01:00
parent b6d6355c6c
commit cbfb20505d
11 changed files with 359 additions and 128 deletions

View file

@ -5,6 +5,80 @@
* 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('/js/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 = {}) {
@ -314,83 +388,109 @@ document.addEventListener('alpine:init', () => {
// ── Composant: dashboardPage ────────────────────────────────────────────
Alpine.data('dashboardPage', () => ({
resources: [],
ws: null,
_unsubWS: null,
wsStatus: 'connecting',
configOpen: false,
editMode: false,
dragSrcIdx: null,
dragOverIdx: null,
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 {
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' },
]
}
const saved = JSON.parse(localStorage.getItem('pxp_widgets') || 'null')
if (!saved) return defaults
// Fusionner pour ajouter d'éventuels nouveaux widgets par défaut
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)) },
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
// ── Edition
toggleEdit() { this.editMode = !this.editMode },
toggleSize(w) {
w.size = (w.size || 1) === 1 ? 2 : 1
this.saveWidgets()
},
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()
},
// ── DnD avec preview
onDragStart(idx) {
this.dragSrcIdx = idx
},
onDragEnter(idx) {
if (this.dragSrcIdx !== null && this.dragSrcIdx !== idx) this.dragOverIdx = idx
},
onDragLeave(idx) {
if (this.dragOverIdx === idx) this.dragOverIdx = null
},
onDragEnd() {
this.dragSrcIdx = null
this.dragOverIdx = null
},
onDrop(idx) {
if (this.dragSrcIdx === null || this.dragSrcIdx === idx) {
this.onDragEnd(); return
}
const moved = this.widgets.splice(this.dragSrcIdx, 1)[0]
this.widgets.splice(idx, 0, moved)
this.onDragEnd()
this.saveWidgets()
},
// ── Data
async init() {
await this.fetchResources()
this.connectWS()
await this.connectWS()
},
destroy() {
if (this.ws) this.ws.close()
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'
}
if (res.ok) { this.resources = await res.json() || []; this.wsStatus = 'connected' }
} catch (e) { /* WS prendra le relais */ }
},
connectWS() {
async 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' }
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 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') },
get visibleWidgets() { return this.widgets.filter(w => w.visible) },
get hiddenWidgets() { 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) },
}))
@ -431,43 +531,38 @@ document.addEventListener('alpine:init', () => {
// ── Composant: proxmoxPage ──────────────────────────────────────────────
Alpine.data('proxmoxPage', () => ({
resources: [],
ws: null,
_unsubWS: null,
wsStatus: 'connecting',
actionLoading: {},
async init() {
await this.fetchResources()
this.connectWS()
await this.connectWS()
},
destroy() { if (this.ws) this.ws.close() },
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 = 'ok'
this.wsStatus = 'connected'
}
} catch (e) { /* WS prendra le relais */ }
},
connectWS() {
async 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' }
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) {