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

86
frontend/js/ws.sw.js Normal file
View file

@ -0,0 +1,86 @@
/**
* ProxmoxPanel WebSocket Service Worker
* Maintient les connexions WS en vie entre les navigations Swup.
* Les pages s'abonnent/désabonnent via postMessage.
*/
'use strict'
// channel → { ws: WebSocket|null, url: string, clientIds: Set<string>, retryDelay: number }
const connections = new Map()
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', e =>
e.waitUntil(self.clients.claim())
)
self.addEventListener('message', event => {
const { type, channel, url, payload } = event.data || {}
if (!channel) return
const clientId = event.source?.id
if (!clientId) return
switch (type) {
case 'WS_SUBSCRIBE': {
let conn = connections.get(channel)
if (!conn) {
conn = { ws: null, url: null, clientIds: new Set(), retryDelay: 2000 }
connections.set(channel, conn)
}
conn.clientIds.add(clientId)
if (url) conn.url = url
ensureWS(channel)
break
}
case 'WS_UNSUBSCRIBE': {
const conn = connections.get(channel)
if (conn) conn.clientIds.delete(clientId)
break
}
case 'WS_SEND': {
const conn = connections.get(channel)
if (conn?.ws?.readyState === 1) conn.ws.send(payload)
break
}
}
})
function ensureWS(channel) {
const conn = connections.get(channel)
if (!conn?.url) return
if (conn.ws && conn.ws.readyState < 2) return // CONNECTING (0) ou OPEN (1)
const ws = new WebSocket(conn.url)
conn.ws = ws
conn.retryDelay = 2000
notify(channel, 'WS_STATUS', { status: 'connecting' })
ws.onopen = () => {
conn.retryDelay = 2000
notify(channel, 'WS_STATUS', { status: 'connected' })
}
ws.onmessage = e => notify(channel, 'WS_MESSAGE', { data: e.data })
ws.onerror = () => notify(channel, 'WS_STATUS', { status: 'error' })
ws.onclose = () => {
notify(channel, 'WS_STATUS', { status: 'disconnected' })
// Backoff exponentiel plafonné à 30s
const delay = conn.retryDelay
conn.retryDelay = Math.min(conn.retryDelay * 1.5, 30000)
setTimeout(() => ensureWS(channel), delay)
}
}
async function notify(channel, type, extra) {
const conn = connections.get(channel)
if (!conn || conn.clientIds.size === 0) return
const allClients = await self.clients.matchAll({ type: 'window' })
for (const client of allClients) {
if (conn.clientIds.has(client.id)) {
client.postMessage({ channel, type, ...extra })
}
}
}