fix(realtime): l'app terrain et le panneau pannes SURVIVENT aux redéploiements du hub
Même classe de bug que 187115c, sur deux flux SSE qui manquaient la reconnexion :
- TechTasksPage (app terrain) : AUCUN onerror → au restart du hub (502→CLOSED) le technicien perdait TOUT le
temps réel (nouvelles tâches, changements de statut) jusqu'au rechargement manuel. Ajout reconnexion 3 s + resync
loadTasks() à la RE-connexion (le hub ne rejoue pas les événements manqués).
- OutageAlertsPanel : au restart, tombait en repli poll 30 s et n'en sortait JAMAIS (ni retour temps réel, ni
resync immédiat). Ajout reconnexion 3 s + à la RE-connexion : arrêt du poll + fetchActive() pour rattraper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
187115cb56
commit
89f7efc4ba
|
|
@ -49,6 +49,8 @@ const alerts = ref([])
|
||||||
const expanded = ref(true)
|
const expanded = ref(true)
|
||||||
let es = null
|
let es = null
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
|
let opened = false // 1re connexion vs RE-connexion (→ resync)
|
||||||
|
let reconnectTimer = null
|
||||||
|
|
||||||
function timeAgo (ts) {
|
function timeAgo (ts) {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
|
|
@ -94,6 +96,14 @@ function connectSSE () {
|
||||||
if (es) es.close()
|
if (es) es.close()
|
||||||
es = new EventSource(`${HUB_URL}/sse?topics=network`)
|
es = new EventSource(`${HUB_URL}/sse?topics=network`)
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
// (Re)connecté : on stoppe le repli poll. Sur une RE-connexion (ex. après un redéploiement du hub), on
|
||||||
|
// resynchronise l'état courant — le hub ne rejoue pas les incidents survenus pendant la coupure.
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
if (opened) fetchActive()
|
||||||
|
opened = true
|
||||||
|
}
|
||||||
|
|
||||||
es.addEventListener('outage-alert', e => {
|
es.addEventListener('outage-alert', e => {
|
||||||
try { addOrUpdateAlert({ ...JSON.parse(e.data), severity: 'critical' }) } catch {}
|
try { addOrUpdateAlert({ ...JSON.parse(e.data), severity: 'critical' }) } catch {}
|
||||||
})
|
})
|
||||||
|
|
@ -128,10 +138,18 @@ function connectSSE () {
|
||||||
})
|
})
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
// Fallback: poll every 30s
|
// Repli immédiat : tant que le flux est coupé, on interroge toutes les 30 s.
|
||||||
if (!pollTimer) {
|
if (!pollTimer) {
|
||||||
pollTimer = setInterval(fetchActive, 30000)
|
pollTimer = setInterval(fetchActive, 30000)
|
||||||
}
|
}
|
||||||
|
// Si le flux est mort DÉFINITIVEMENT (502 pendant un redéploiement du hub → CLOSED, plus de reconnexion
|
||||||
|
// native), on relance après 3 s. onopen nettoiera le poll et resynchronisera. Sinon le panneau resterait
|
||||||
|
// en mode poll 30 s au lieu du temps réel jusqu'au prochain rechargement.
|
||||||
|
if (es && es.readyState === EventSource.CLOSED) {
|
||||||
|
es = null
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = setTimeout(connectSSE, 3000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +159,7 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
if (es) es.close()
|
if (es) es.close()
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -557,9 +557,17 @@ function goDetail (job) {
|
||||||
// Just refresh the list — cheaper than surgical state merging and avoids
|
// Just refresh the list — cheaper than surgical state merging and avoids
|
||||||
// race conditions when multiple events arrive in quick succession.
|
// race conditions when multiple events arrive in quick succession.
|
||||||
let sseSource = null
|
let sseSource = null
|
||||||
|
let sseOpened = false // 1re connexion vs RE-connexion (→ resync)
|
||||||
|
let sseReconnect = null
|
||||||
function connectSSE () {
|
function connectSSE () {
|
||||||
try {
|
try {
|
||||||
sseSource = new EventSource(`${HUB_URL}/sse?topics=dispatch`)
|
sseSource = new EventSource(`${HUB_URL}/sse?topics=dispatch`)
|
||||||
|
sseSource.onopen = () => {
|
||||||
|
// Resync sur RE-connexion (ex. après un redéploiement du hub) : le hub ne rejoue pas les événements
|
||||||
|
// manqués pendant la coupure, donc on recharge la liste pour rattraper les statuts changés entre-temps.
|
||||||
|
if (sseOpened) loadTasks()
|
||||||
|
sseOpened = true
|
||||||
|
}
|
||||||
sseSource.addEventListener('job-unblocked', (e) => {
|
sseSource.addEventListener('job-unblocked', (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data)
|
const data = JSON.parse(e.data)
|
||||||
|
|
@ -593,9 +601,20 @@ function connectSSE () {
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
// Survit aux redéploiements du hub : si le flux meurt DÉFINITIVEMENT (502 pendant le restart → CLOSED,
|
||||||
|
// plus de reconnexion native), on relance après 3 s — sinon le technicien perd le temps réel (nouvelles
|
||||||
|
// tâches, changements de statut) jusqu'au prochain rechargement manuel. onopen resynchronisera.
|
||||||
|
sseSource.onerror = () => {
|
||||||
|
if (sseSource && sseSource.readyState === EventSource.CLOSED) {
|
||||||
|
sseSource = null
|
||||||
|
if (sseReconnect) clearTimeout(sseReconnect)
|
||||||
|
sseReconnect = setTimeout(connectSSE, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
function disconnectSSE () {
|
function disconnectSSE () {
|
||||||
|
if (sseReconnect) { clearTimeout(sseReconnect); sseReconnect = null }
|
||||||
if (sseSource) { sseSource.close(); sseSource = null }
|
if (sseSource) { sseSource.close(); sseSource = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page class="q-pa-md" style="max-width:980px">
|
|
||||||
<div class="row items-center q-mb-xs">
|
|
||||||
<div class="text-h5">Thème & couleurs</div>
|
|
||||||
<q-space />
|
|
||||||
<q-btn flat no-caps icon="restart_alt" label="Réinitialiser" @click="reset" class="q-mr-sm" />
|
|
||||||
<q-btn unelevated no-caps color="primary" icon="save" label="Enregistrer" @click="save" />
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-7 q-mb-md">
|
|
||||||
Les couleurs principales de l'app, regroupées. Modifie en direct (aperçu immédiat) puis « Enregistrer ».
|
|
||||||
Les éléments qui utilisent les variables <code>--ops-*</code> suivent automatiquement — objectif : réduire et uniformiser la palette.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="q-mb-lg">
|
|
||||||
<div class="text-subtitle2 q-mb-xs">Palettes de départ</div>
|
|
||||||
<q-btn outline no-caps color="green-7" icon="eco" label="Moderne (défaut)" @click="applyPreset('default')" class="q-mr-sm q-mb-xs">
|
|
||||||
<q-tooltip>Vert accessible + neutres — la palette par défaut, inspirée de la marque</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn outline no-caps color="green-6" icon="bolt" label="Vif (lime)" @click="applyPreset('vif')" class="q-mr-sm q-mb-xs">
|
|
||||||
<q-tooltip>Vert lime éclatant (#22c11f) — plus vibrant, contraste texte moindre sur boutons pleins</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn outline no-caps color="primary" icon="palette" label="Classique (indigo)" @click="applyPreset('indigo')" class="q-mb-xs" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div v-for="g in groups" :key="g.title" class="col-12 col-md-6">
|
|
||||||
<q-card flat bordered>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-subtitle1 text-weight-bold">{{ g.title }}</div>
|
|
||||||
<div v-if="g.hint" class="text-caption text-grey-6 q-mb-sm">{{ g.hint }}</div>
|
|
||||||
<div v-for="t in g.tokens" :key="t.var" class="row items-center q-py-xs no-wrap" style="gap:10px">
|
|
||||||
<input type="color" :value="work[t.var]" @input="e => set(t, e.target.value)"
|
|
||||||
style="width:40px;height:34px;border:1px solid var(--ops-border);border-radius:8px;padding:2px;background:none;cursor:pointer;flex:0 0 auto" />
|
|
||||||
<div class="col">
|
|
||||||
<div class="text-body2">{{ t.label }}</div>
|
|
||||||
<code class="text-caption text-grey-6">{{ t.var }}</code>
|
|
||||||
</div>
|
|
||||||
<q-input dense outlined v-model="work[t.var]" @update:model-value="v => set(t, v)" style="width:104px;flex:0 0 auto" />
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-card flat bordered class="q-mt-md">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Aperçu</div>
|
|
||||||
<div class="row items-center q-gutter-sm">
|
|
||||||
<q-btn unelevated color="primary" no-caps label="Bouton accent" />
|
|
||||||
<q-btn outline color="primary" no-caps label="Contour" />
|
|
||||||
<span class="ops-badge active">Actif</span>
|
|
||||||
<span class="ops-badge open">Ouvert</span>
|
|
||||||
<span class="ops-badge inactive">Inactif</span>
|
|
||||||
<q-badge color="positive" label="Succès" />
|
|
||||||
<q-badge color="warning" label="Attention" />
|
|
||||||
<q-badge color="negative" label="Erreur" />
|
|
||||||
<a href="#" @click.prevent style="color:var(--ops-accent);font-weight:600">Lien accent</a>
|
|
||||||
</div>
|
|
||||||
<div class="q-mt-sm" style="background:var(--ops-bg);border:1px solid var(--ops-border);border-radius:8px;padding:10px">
|
|
||||||
<span style="color:var(--ops-text)">Texte principal sur fond de page.</span>
|
|
||||||
<span style="color:var(--ops-text-muted)"> · texte discret</span>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<div class="text-caption text-grey-6 q-mt-md">
|
|
||||||
Enregistré dans ce navigateur (aperçu). Pour appliquer à toute l'équipe, on baked la palette choisie dans <code>css/app.scss</code>.
|
|
||||||
Les couleurs encore codées en dur dans certaines pages (≈2000) ne suivront qu'après migration vers les variables.
|
|
||||||
</div>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, onMounted } from 'vue'
|
|
||||||
import { useQuasar } from 'quasar'
|
|
||||||
import { THEME_GROUPS, PRESETS, applyTheme, saveTheme, resetTheme, currentValues } from 'src/composables/useTheme'
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
|
||||||
const groups = THEME_GROUPS
|
|
||||||
const work = reactive({})
|
|
||||||
onMounted(() => { Object.assign(work, currentValues()) })
|
|
||||||
|
|
||||||
function set (t, val) { if (!val) return; work[t.var] = val; applyTheme({ ...work }) }
|
|
||||||
function applyPreset (name) { Object.assign(work, PRESETS[name]); applyTheme({ ...work }) }
|
|
||||||
function save () { saveTheme({ ...work }); $q.notify({ type: 'positive', message: 'Thème enregistré (ce navigateur)' }) }
|
|
||||||
function reset () { resetTheme(); Object.assign(work, currentValues()); $q.notify({ type: 'info', message: 'Thème réinitialisé (défaut)' }) }
|
|
||||||
</script>
|
|
||||||
Loading…
Reference in New Issue
Block a user