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:
parent
b6d6355c6c
commit
cbfb20505d
11 changed files with 359 additions and 128 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue