feat(client): migliora robustezza connessioni WebSocket su display e controller

Aggiunge gestione riconnessione con backoff esponenziale e protezione da reconnect multipli.

Migliora cleanup su unmount/HMR per evitare listener e timeout pendenti.

Uniforma gestione errori e stato connessione lato client.

Semplifica etichette pulsanti controller rimuovendo emoji e aggiorna commenti.
This commit is contained in:
2026-02-10 09:54:10 +01:00
parent f7c4fdc2ef
commit 082a52dc3e
3 changed files with 311 additions and 51 deletions

View File

@@ -1,12 +1,12 @@
<template> <template>
<section class="controller-page"> <section class="controller-page">
<!-- Connection status bar --> <!-- Barra di stato connessione -->
<div class="conn-bar" :class="{ connected: wsConnected }"> <div class="conn-bar" :class="{ connected: wsConnected }">
<span class="dot"></span> <span class="dot"></span>
{{ wsConnected ? 'Connesso' : 'Connessione...' }} {{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div> </div>
<!-- Score preview --> <!-- Anteprima punteggio -->
<div class="score-preview"> <div class="score-preview">
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })"> <div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
<div class="team-name">{{ state.sp.nomi.home }}</div> <div class="team-name">{{ state.sp.nomi.home }}</div>
@@ -22,14 +22,14 @@
</div> </div>
</div> </div>
<!-- Undo row --> <!-- Riga annulla punto -->
<div class="undo-row"> <div class="undo-row">
<button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })"> <button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })">
ANNULLA PUNTO ANNULLA PUNTO
</button> </button>
</div> </div>
<!-- Set buttons --> <!-- Pulsanti set -->
<div class="action-row"> <div class="action-row">
<button class="btn btn-set home-bg" @click="sendAction({ type: 'incSet', team: 'home' })"> <button class="btn btn-set home-bg" @click="sendAction({ type: 'incSet', team: 'home' })">
SET {{ state.sp.nomi.home }} SET {{ state.sp.nomi.home }}
@@ -39,35 +39,35 @@
</button> </button>
</div> </div>
<!-- Controls --> <!-- Controlli principali -->
<div class="controls"> <div class="controls">
<button class="btn btn-ctrl" @click="sendAction({ type: 'cambiaPalla' })" :disabled="!isPunteggioZeroZero"> <button class="btn btn-ctrl" @click="sendAction({ type: 'cambiaPalla' })" :disabled="!isPunteggioZeroZero">
🏐 Cambio Palla Cambio Palla
</button> </button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleFormazione' })"> <button class="btn btn-ctrl" @click="sendAction({ type: 'toggleFormazione' })">
{{ state.visuForm ? '🔢 Punteggio' : '📋 Formazioni' }} {{ state.visuForm ? 'Punteggio' : 'Formazioni' }}
</button> </button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleStriscia' })"> <button class="btn btn-ctrl" @click="sendAction({ type: 'toggleStriscia' })">
📊 Striscia Striscia
</button> </button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleOrder' })"> <button class="btn btn-ctrl" @click="sendAction({ type: 'toggleOrder' })">
🔄 Inverti Inverti
</button> </button>
<button class="btn btn-ctrl" @click="speak()"> <button class="btn btn-ctrl" @click="speak()">
🔊 Voce Voce
</button> </button>
<button class="btn btn-ctrl" @click="openConfig()"> <button class="btn btn-ctrl" @click="openConfig()">
Config Config
</button> </button>
<button class="btn btn-ctrl" @click="openCambiTeam()"> <button class="btn btn-ctrl" @click="openCambiTeam()">
🔀 Cambi Cambi
</button> </button>
<button class="btn btn-danger" @click="confirmReset = true"> <button class="btn btn-danger" @click="confirmReset = true">
🗑 Reset Reset
</button> </button>
</div> </div>
<!-- Reset confirmation --> <!-- Finestra conferma reset -->
<div class="overlay" v-if="confirmReset" @click.self="confirmReset = false"> <div class="overlay" v-if="confirmReset" @click.self="confirmReset = false">
<div class="dialog"> <div class="dialog">
<div class="dialog-title">Azzero punteggio?</div> <div class="dialog-title">Azzero punteggio?</div>
@@ -78,7 +78,7 @@
</div> </div>
</div> </div>
<!-- Config dialog --> <!-- Finestra configurazione -->
<div class="overlay" v-if="showConfig" @click.self="showConfig = false"> <div class="overlay" v-if="showConfig" @click.self="showConfig = false">
<div class="dialog dialog-config"> <div class="dialog dialog-config">
<div class="dialog-title">Configurazione</div> <div class="dialog-title">Configurazione</div>
@@ -143,7 +143,7 @@
</div> </div>
</div> </div>
<!-- Cambi team selection --> <!-- Selezione squadra per cambi -->
<div class="overlay" v-if="showCambiTeam" @click.self="showCambiTeam = false"> <div class="overlay" v-if="showCambiTeam" @click.self="showCambiTeam = false">
<div class="dialog"> <div class="dialog">
<div class="dialog-title">Scegli squadra</div> <div class="dialog-title">Scegli squadra</div>
@@ -154,7 +154,7 @@
</div> </div>
</div> </div>
<!-- Cambi dialog --> <!-- Finestra gestione cambi -->
<div class="overlay" v-if="showCambi" @click.self="closeCambi()"> <div class="overlay" v-if="showCambi" @click.self="closeCambi()">
<div class="dialog"> <div class="dialog">
<div class="dialog-title">{{ state.sp.nomi[cambiTeam] }}: CAMBIO</div> <div class="dialog-title">{{ state.sp.nomi[cambiTeam] }}: CAMBIO</div>
@@ -181,6 +181,10 @@ export default {
return { return {
ws: null, ws: null,
wsConnected: false, wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000,
confirmReset: false, confirmReset: false,
showConfig: false, showConfig: false,
showCambiTeam: false, showCambiTeam: false,
@@ -236,44 +240,185 @@ export default {
}, },
mounted() { mounted() {
this.connectWebSocket() this.connectWebSocket()
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
}, },
beforeUnmount() { beforeUnmount() {
if (this.ws) this.ws.close() // Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Controller] Error closing WebSocket:', err)
}
this.ws = null
}
}, },
methods: { methods: {
connectWebSocket() { connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Controller] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Controller] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${location.host}` const wsUrl = `${protocol}//${location.host}`
try {
this.ws = new WebSocket(wsUrl) this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Controller] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => { this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Controller] Connected to server')
// Invia la registrazione solo se la connessione e realmente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'controller' })) this.ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
} catch (err) {
console.error('[Controller] Failed to register:', err)
}
}
} }
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === 'state') { if (msg.type === 'state') {
this.state = msg.state this.state = msg.state
} else if (msg.type === 'error') {
console.error('[Controller] Server error:', msg.message)
// Fornisce feedback di errore all'utente.
this.showErrorFeedback(msg.message)
} }
} catch (e) { } catch (e) {
console.error('WS parse error:', e) console.error('[Controller] Parse error:', e)
} }
} }
this.ws.onclose = () => { this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false this.wsConnected = false
setTimeout(() => this.connectWebSocket(), 2000) console.log('[Controller] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Controller] Clean close, not reconnecting')
return
} }
this.ws.onerror = () => { this.wsConnected = false } this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Controller] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Controller] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
}, },
sendAction(action) { sendAction(action) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'action', action })) console.warn('[Controller] Cannot send action: not connected')
this.showErrorFeedback('Non connesso al server')
return
} }
// Valida l'azione prima dell'invio.
if (!action || !action.type) {
console.error('[Controller] Invalid action format:', action)
return
}
try {
this.ws.send(JSON.stringify({ type: 'action', action }))
} catch (err) {
console.error('[Controller] Failed to send action:', err)
this.showErrorFeedback('Errore invio comando')
}
},
showErrorFeedback(message) {
// Feedback visivo degli errori: attualmente solo log su console.
// In futuro puo essere esteso con notifiche a comparsa (toast).
console.error('[Controller] Error:', message)
}, },
doReset() { doReset() {
@@ -356,7 +501,7 @@ export default {
user-select: none; user-select: none;
} }
/* Connection bar */ /* Barra stato connessione */
.conn-bar { .conn-bar {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -384,7 +529,7 @@ export default {
background: white; background: white;
} }
/* Score preview */ /* Anteprima punteggio */
.score-preview { .score-preview {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -443,7 +588,7 @@ export default {
height: 20px; height: 20px;
} }
/* Undo row */ /* Riga annulla punto */
.undo-row { .undo-row {
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -462,7 +607,7 @@ export default {
background: rgba(255,100,50,0.2); background: rgba(255,100,50,0.2);
} }
/* Set buttons */ /* Pulsanti set */
.action-row { .action-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -482,7 +627,7 @@ export default {
transform: scale(0.97); transform: scale(0.97);
} }
/* Controls grid */ /* Griglia controlli */
.controls { .controls {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -526,7 +671,7 @@ export default {
background: rgba(198, 40, 40, 0.45); background: rgba(198, 40, 40, 0.45);
} }
/* Overlays & Dialogs */ /* Overlay e finestre modali */
.overlay { .overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -593,7 +738,7 @@ export default {
opacity: 0.4; opacity: 0.4;
} }
/* Form groups */ /* Gruppi form */
.form-group { .form-group {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -637,7 +782,7 @@ export default {
border-color: #64b5f6; border-color: #64b5f6;
} }
/* Form grid */ /* Griglia formazione */
.form-grid { .form-grid {
background: rgba(205, 133, 63, 0.15); background: rgba(205, 133, 63, 0.15);
border: 2px solid rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.15);
@@ -655,7 +800,7 @@ export default {
margin: 4px 0; margin: 4px 0;
} }
/* Mode buttons */ /* Pulsanti modalita */
.mode-buttons { .mode-buttons {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -675,7 +820,7 @@ export default {
color: white; color: white;
} }
/* Cambi */ /* Sezione cambi */
.cambi-container { .cambi-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -2,7 +2,7 @@
<section class="display-page"> <section class="display-page">
<div class="campo"> <div class="campo">
<span v-if="state.order"> <span v-if="state.order">
<!-- home guest --> <!-- Ordine visualizzazione: home / guest -->
<div class="hea home"> <div class="hea home">
<span :style="{ 'float': 'left' }"> <span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }} {{ state.sp.nomi.home }}
@@ -46,7 +46,7 @@
</span> </span>
<span v-else> <span v-else>
<!-- guest home --> <!-- Ordine visualizzazione: guest / home -->
<div class="hea guest"> <div class="hea guest">
<span :style="{ 'float': 'left' }"> <span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }} {{ state.sp.nomi.guest }}
@@ -104,7 +104,7 @@
</div> </div>
</div> </div>
<!-- Connection status indicator --> <!-- Indicatore stato connessione -->
<div class="connection-status" :class="{ connected: wsConnected, disconnected: !wsConnected }"> <div class="connection-status" :class="{ connected: wsConnected, disconnected: !wsConnected }">
<span class="dot"></span> <span class="dot"></span>
{{ wsConnected ? '' : 'Disconnesso' }} {{ wsConnected ? '' : 'Disconnesso' }}
@@ -120,6 +120,10 @@ export default {
return { return {
ws: null, ws: null,
wsConnected: false, wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000, // Ritardo massimo di riconnessione: 30 secondi
state: { state: {
order: true, order: true,
visuForm: false, visuForm: false,
@@ -142,51 +146,162 @@ export default {
}, },
mounted() { mounted() {
this.connectWebSocket() this.connectWebSocket()
// Fullscreen on mobile // Attiva la modalita fullscreen su dispositivi mobili.
if (this.isMobile()) { if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {} try { document.documentElement.requestFullscreen() } catch (e) {}
} }
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
}, },
beforeUnmount() { beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) { if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close() this.ws.close()
} }
} catch (err) {
console.error('[Display] Error closing WebSocket:', err)
}
this.ws = null
}
}, },
methods: { methods: {
isMobile() { isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}, },
connectWebSocket() { connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Display] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${location.host}` const wsUrl = `${protocol}//${location.host}`
try {
this.ws = new WebSocket(wsUrl) this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Display] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => { this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true this.wsConnected = true
// Register as display this.reconnectAttempts = 0
console.log('[Display] Connected to server')
// Registra il client come display solo con connessione effettivamente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'display' })) this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
} catch (err) {
console.error('[Display] Failed to register:', err)
}
}
} }
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === 'state') { if (msg.type === 'state') {
this.state = msg.state this.state = msg.state
} else if (msg.type === 'error') {
console.error('[Display] Server error:', msg.message)
} }
} catch (e) { } catch (e) {
console.error('Error parsing WS message:', e) console.error('[Display] Error parsing message:', e)
} }
} }
this.ws.onclose = () => { this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false this.wsConnected = false
// Auto-reconnect after 2 seconds console.log('[Display] Disconnected from server', event.code, event.reason)
setTimeout(() => this.connectWebSocket(), 2000)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Display] Clean close, not reconnecting')
return
} }
this.ws.onerror = () => { this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Display] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false this.wsConnected = false
} }
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Display] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
} }
} }
} }

View File

@@ -53,7 +53,7 @@ button:focus-visible {
font-size: xx-large; font-size: xx-large;
} }
.hea span { .hea span {
/* border: 1px solid #f3fb00; */ /* Bordo di debug: border: 1px solid #f3fb00; */
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
border-radius: 5px; border-radius: 5px;