feat: unified rail shell, robust live sync, task board performance
Shell: - One shared NexusLayout for ALL routes (dashboard + pages): compact 68px icon rail with hover-expand overlay, replaces both old sidebars + topbars - Single flat nav source (railNav) — same menu everywhere incl. Settings - Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup, NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView Live updates: - Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and ChannelReader.ReadAsync were re-invoked while pending — every published update threw InvalidOperationException and killed ALL live streams - liveSync store: treat graceful stream close as disconnect (was stuck connected=true with polling stopped -> page frozen until manual reload), fast first retry, heartbeat watchdog (65s), reconnect on online/visibility - One app-wide SSE connection owned by the layout instead of per-view connect/disconnect churn; removed duplicate live-sync.ts store Performance: - Board endpoint: drop nested childTasks duplication (counts stay) — payload 104KB -> 65KB; SSE snapshots shrink equally - nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire); text/event-stream excluded to keep SSE unbuffered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,9 +50,11 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
error: null as string | null,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
watchdogTimer: null as ReturnType<typeof setInterval> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
forUser: 'bao',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.forUser = forUser
|
||||
this.controller = new AbortController()
|
||||
this.startWatchdog()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
|
||||
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
|
||||
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
|
||||
} catch (error) {
|
||||
if (this.controller?.signal.aborted) return
|
||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
if (!this.controller?.signal.aborted) {
|
||||
console.warn('[liveSync] stream failed', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
}
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
|
||||
if (this.controller?.signal.aborted) return
|
||||
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
},
|
||||
|
||||
/** Erzwingt einen frischen Stream (Watchdog / visibilitychange / online). */
|
||||
reconnectNow() {
|
||||
const forUser = this.forUser
|
||||
this.disconnect()
|
||||
this.connect(forUser)
|
||||
},
|
||||
|
||||
startWatchdog() {
|
||||
if (this.watchdogTimer) return
|
||||
this.watchdogTimer = setInterval(() => {
|
||||
if (!this.connected || !this.lastEventAt) return
|
||||
// Heartbeat kommt alle 20s — >65s Stille heißt: Verbindung ist tot,
|
||||
// auch wenn der Browser den fetch-Stream noch für offen hält.
|
||||
const silentMs = Date.now() - new Date(this.lastEventAt).getTime()
|
||||
if (silentMs > 65000) {
|
||||
console.warn('[liveSync] heartbeat timeout, reconnecting')
|
||||
this.reconnectNow()
|
||||
}
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
stopWatchdog() {
|
||||
if (this.watchdogTimer) {
|
||||
clearInterval(this.watchdogTimer)
|
||||
this.watchdogTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -148,6 +184,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.stopWatchdog()
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
|
||||
// danach sanft hochstaffeln bis 30s.
|
||||
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
|
||||
Reference in New Issue
Block a user