feat: unified rail shell, robust live sync, task board performance
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped

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:
2026-07-10 13:25:26 +02:00
parent 86ceb2bcce
commit f564ecfbc7
19 changed files with 540 additions and 2057 deletions
+49 -10
View File
@@ -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