fix(ui): debounce connection-lost banner so transient ws blips don't flash

The reconnect banner showed 'Connection lost'/'Reconnecting' instantly on every
socket close, even ones that recover in 100ms-2s (load spikes, Tailscale/relay
TCP resets). On a healthy node the drops are brief and self-healing, but each one
flashed a jarring banner, reading as constant instability.

Debounce the transient banner by 2.5s: only surface after the connection issue
persists past the grace window; hide immediately on recovery. Deliberate server
lifecycle transitions (restart/shutdown) bypass the debounce and still show at
once. A genuine persistent outage keeps isOffline true and surfaces after 2.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-24 04:58:54 -04:00
parent 7d89b4d8b2
commit 3e3016f2bd

View File

@ -1,9 +1,12 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<!-- Offline Banner --> <!-- Lifecycle / Offline Banner.
Server restart/shutdown is deliberate shown immediately. A plain
connection blip is debounced (showConnIssue) so transient sub-grace
reconnects don't flash. -->
<Transition name="conn-banner"> <Transition name="conn-banner">
<div <div
v-if="isOffline && !store.isReconnecting && store.isAuthenticated" v-if="(showLifecycle || showConnectionLost)"
class="conn-banner-overlay" class="conn-banner-overlay"
> >
<div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl"> <div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl">
@ -17,10 +20,10 @@
</div> </div>
</Transition> </Transition>
<!-- Reconnecting Banner --> <!-- Reconnecting Banner (debounced) -->
<Transition name="conn-banner"> <Transition name="conn-banner">
<div <div
v-if="store.isReconnecting && store.isAuthenticated" v-if="showReconnecting"
class="conn-banner-overlay" class="conn-banner-overlay"
> >
<div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl"> <div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl">
@ -35,7 +38,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
const store = useAppStore() const store = useAppStore()
@ -43,6 +46,58 @@ const store = useAppStore()
const isOffline = computed(() => store.isOffline) const isOffline = computed(() => store.isOffline)
const isRestarting = computed(() => store.isRestarting) const isRestarting = computed(() => store.isRestarting)
const isShuttingDown = computed(() => store.isShuttingDown) const isShuttingDown = computed(() => store.isShuttingDown)
// A deliberate server lifecycle transition (restart/shutdown) is real and
// user-initiated surface it immediately, no debounce.
const isLifecycleTransition = computed(() => isRestarting.value || isShuttingDown.value)
const showLifecycle = computed(() => isLifecycleTransition.value && store.isAuthenticated)
// A plain connection blip (offline or reconnecting, not a lifecycle transition).
// The overwhelming majority recover within a second or two (load spikes,
// Tailscale/relay TCP resets), so showing the banner instantly makes a healthy
// node read as unstable. Debounce: only surface after the issue persists past a
// grace window; hide immediately on recovery.
const hasConnIssue = computed(
() => (store.isReconnecting || isOffline.value) && !isLifecycleTransition.value
)
const SHOW_DELAY_MS = 2500
const showConnIssue = ref(false)
let pendingTimer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
}
watch(
hasConnIssue,
(issue) => {
clearTimer()
if (issue) {
pendingTimer = setTimeout(() => {
showConnIssue.value = true
pendingTimer = null
}, SHOW_DELAY_MS)
} else {
// Recovered before the grace window elapsed hide at once.
showConnIssue.value = false
}
},
{ immediate: true }
)
onUnmounted(clearTimer)
// Debounced visual states the template renders.
const showReconnecting = computed(
() => showConnIssue.value && store.isReconnecting && store.isAuthenticated
)
const showConnectionLost = computed(
() => showConnIssue.value && isOffline.value && !store.isReconnecting && store.isAuthenticated
)
</script> </script>
<style scoped> <style scoped>