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
|
|
@ -431,6 +431,8 @@ input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||
border-bottom: 1px solid var(--neu-border);
|
||||
min-height: var(--navbar-height);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
|
|
@ -447,10 +449,6 @@ input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@
|
|||
.total-badge { font-size: .875rem; font-weight: 600; color: var(--neu-primary); }
|
||||
.targets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.target-card { padding: 1rem; display: flex; flex-direction: column; gap: .75rem; }
|
||||
|
|
@ -548,6 +548,58 @@
|
|||
gap: .375rem;
|
||||
}
|
||||
|
||||
/* Widget edit mode */
|
||||
.widget-size-2 { grid-column: span 2; }
|
||||
@media (max-width: 720px) { .widget-size-2 { grid-column: span 1; } }
|
||||
|
||||
.widget.is-dragging { opacity: 0.35; transform: scale(.97); }
|
||||
.widget.drag-over { outline: 2px dashed var(--neu-primary); transform: scale(1.02); }
|
||||
|
||||
.widget-edit-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .375rem;
|
||||
padding: .25rem 0 .75rem;
|
||||
border-bottom: 1px solid var(--neu-border);
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.widget-edit-bar-title { flex: 1; font-size: .75rem; font-weight: 600; color: var(--neu-text-muted); text-transform: uppercase; }
|
||||
|
||||
.edit-mode-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .625rem 1rem;
|
||||
border-radius: var(--neu-radius-sm);
|
||||
background: rgba(108, 142, 244, 0.1);
|
||||
border: 1px solid var(--neu-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: .875rem;
|
||||
color: var(--neu-primary);
|
||||
}
|
||||
.edit-mode-banner i { font-size: 1.1rem; }
|
||||
|
||||
.widget-add-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.widget-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem;
|
||||
border-radius: var(--neu-radius-sm);
|
||||
border: 1px dashed var(--neu-border);
|
||||
background: transparent;
|
||||
color: var(--neu-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: .875rem;
|
||||
transition: all .15s;
|
||||
}
|
||||
.widget-add-btn:hover { border-color: var(--neu-primary); color: var(--neu-primary); }
|
||||
|
||||
/* ── Profil / préférences ────────────────────────────────────────────────────── */
|
||||
.settings-section {
|
||||
margin-bottom: 1.25rem;
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
@ -62,47 +59,68 @@
|
|||
|
||||
<div class="page-header">
|
||||
<h2 class="page-title" x-text="t('dashboard.welcome').replace('{name}', $store.auth.user?.username || '')"></h2>
|
||||
<button class="neu-btn neu-btn--sm" @click="configOpen = !configOpen" :title="t('dashboard.addWidget')">
|
||||
<i class="lnid-layout-1"></i>
|
||||
<span x-show="!configOpen" x-text="t('dashboard.addWidget')"></span>
|
||||
<span x-show="configOpen">✕</span>
|
||||
<button class="neu-btn neu-btn--sm" :class="{ 'neu-btn--primary': editMode }" @click="toggleEdit()">
|
||||
<i class="lnid-pen-1"></i>
|
||||
<span x-text="editMode ? '✓ Terminer' : t('dashboard.addWidget')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Config panel widgets -->
|
||||
<div class="widget-config neu-card" x-show="configOpen" x-transition>
|
||||
<h4 class="widget-config-title">Configurer les widgets</h4>
|
||||
<template x-for="(w, idx) in widgets" :key="w.id">
|
||||
<div class="widget-config-row"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(idx)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop(idx)">
|
||||
<i class="lnid-menu-hamburger-1 drag-handle"></i>
|
||||
<span x-text="w.label"></span>
|
||||
<label class="widget-toggle-label">
|
||||
<input type="checkbox" x-model="w.visible" @change="saveWidgets()" />
|
||||
<span x-text="w.visible ? 'Visible' : 'Masqué'"></span>
|
||||
</label>
|
||||
<!-- Mode édition — bannière + widgets masqués -->
|
||||
<div x-show="editMode" x-transition>
|
||||
<div class="edit-mode-banner">
|
||||
<i class="lnid-pen-1"></i>
|
||||
<span>Mode édition — glissez les widgets pour les réordonner, redimensionnez ou masquez-les.</span>
|
||||
</div>
|
||||
|
||||
<!-- Widgets masqués (à ajouter) -->
|
||||
<div x-show="hiddenWidgets.length > 0">
|
||||
<div class="widget-add-grid">
|
||||
<template x-for="w in hiddenWidgets" :key="w.id">
|
||||
<button class="widget-add-btn" @click="showWidget(w.id)">
|
||||
<i class="lnid-add-circle"></i>
|
||||
<span x-text="w.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WS status -->
|
||||
<div class="ws-status" :class="wsStatus" x-show="wsStatus !== 'connected'">
|
||||
<div class="ws-status" :class="wsStatus" x-show="wsStatus !== 'connected'" x-transition>
|
||||
<span x-show="wsStatus === 'connecting'">⌛ Connexion…</span>
|
||||
<span x-show="wsStatus === 'disconnected'">⚠ Déconnecté (reconnexion…)</span>
|
||||
<span x-show="wsStatus === 'error'">✗ Erreur WebSocket</span>
|
||||
</div>
|
||||
|
||||
<!-- Widgets -->
|
||||
<!-- Widgets grid -->
|
||||
<div class="widgets-grid">
|
||||
<template x-for="(w, idx) in widgets.filter(w => w.visible)" :key="w.id">
|
||||
<template x-for="(w, idx) in visibleWidgets" :key="w.id">
|
||||
<div class="neu-card widget"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(widgets.indexOf(w))"
|
||||
:class="{
|
||||
'widget-size-2': w.size === 2,
|
||||
'is-dragging': editMode && dragSrcIdx === widgets.indexOf(w),
|
||||
'drag-over': editMode && dragOverIdx === widgets.indexOf(w),
|
||||
}"
|
||||
:draggable="editMode"
|
||||
@dragstart="editMode && onDragStart(widgets.indexOf(w))"
|
||||
@dragenter.prevent="editMode && onDragEnter(widgets.indexOf(w))"
|
||||
@dragleave="editMode && onDragLeave(widgets.indexOf(w))"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop(widgets.indexOf(w))">
|
||||
@dragend="onDragEnd()"
|
||||
@drop.prevent="editMode && onDrop(widgets.indexOf(w))">
|
||||
|
||||
<!-- Barre d'édition (visible en mode édition) -->
|
||||
<div class="widget-edit-bar" x-show="editMode">
|
||||
<i class="lnid-menu-hamburger-1 drag-handle"></i>
|
||||
<span class="widget-edit-bar-title" x-text="w.label"></span>
|
||||
<button class="neu-btn neu-btn--sm" @click.stop="toggleSize(w)"
|
||||
:title="w.size === 2 ? 'Réduire' : 'Agrandir'">
|
||||
<i :class="w.size === 2 ? 'lnid-compress-arrows' : 'lnid-expand-arrows'"></i>
|
||||
</button>
|
||||
<button class="neu-btn neu-btn--sm" @click.stop="hideWidget(w)" title="Masquer">
|
||||
<i class="lnid-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Widget: status -->
|
||||
<template x-if="w.id === 'status'">
|
||||
|
|
@ -171,8 +189,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<p class="empty-state" x-show="widgets.filter(w => w.visible).length === 0">
|
||||
Aucun widget actif. Cliquez sur "Configurer" pour en ajouter.
|
||||
<p class="empty-state" x-show="visibleWidgets.length === 0 && !editMode">
|
||||
Aucun widget actif — activez le mode édition pour en ajouter.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
86
frontend/js/ws.sw.js
Normal file
86
frontend/js/ws.sw.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,12 +18,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
|
|||
|
|
@ -21,12 +21,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@
|
|||
<body x-data x-init="$store.ui.init()">
|
||||
|
||||
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="toggle()">
|
||||
<i class="sidebar-logo lnid-layout-1"></i>
|
||||
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
|
||||
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
|
||||
<i class="lnid-menu-hamburger-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue