f564ecfbc7
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>
212 lines
7.5 KiB
TypeScript
212 lines
7.5 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { openDashboardLiveStream } from '../services/live'
|
|
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
|
import type { NotificationItem } from './notifications'
|
|
import type { TaskItem } from '../components/dashboard/v2/types'
|
|
import { useTaskStore } from './tasks'
|
|
import { useNotificationStore } from './notifications'
|
|
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
|
|
|
interface NotificationSnapshotDto {
|
|
notifications: NotificationItem[]
|
|
unreadCount: number
|
|
forUser: string
|
|
}
|
|
|
|
interface DashboardLiveSnapshotDto {
|
|
board: BoardGroup
|
|
notifications: NotificationSnapshotDto
|
|
cursor: LiveCursorDto
|
|
}
|
|
|
|
function isBoardGroup(value: unknown): value is BoardGroup {
|
|
const v = value as BoardGroup
|
|
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
|
}
|
|
|
|
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
|
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
|
}
|
|
|
|
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
|
return {
|
|
id: t.id,
|
|
title: t.title,
|
|
agent: t.assignedTo ?? '—',
|
|
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
|
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
|
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
|
detail: t.detail,
|
|
source: t.source,
|
|
}
|
|
}
|
|
|
|
export const useLiveSyncStore = defineStore('liveSync', {
|
|
state: () => ({
|
|
connected: false,
|
|
connecting: false,
|
|
lastEventAt: null as string | null,
|
|
lastHeartbeatAt: null as string | null,
|
|
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: {
|
|
liveIndicatorLabel: (state) => {
|
|
if (state.connecting) return 'Verbinde…'
|
|
if (state.connected) return `Live · #${state.lastSequence}`
|
|
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
|
},
|
|
connectionHealth: (state) => {
|
|
if (state.connected) return 'healthy'
|
|
if (state.connecting) return 'connecting'
|
|
return 'degraded'
|
|
},
|
|
},
|
|
|
|
actions: {
|
|
async connect(forUser = 'bao') {
|
|
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()
|
|
|
|
try {
|
|
const stream = await openDashboardLiveStream((event, data) => {
|
|
this.lastEventAt = new Date().toISOString()
|
|
|
|
if (event === 'heartbeat') {
|
|
const cursor = data as LiveCursorDto
|
|
this.lastHeartbeatAt = cursor.timestamp
|
|
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
|
return
|
|
}
|
|
|
|
if (event === 'snapshot') {
|
|
const snapshot = data as DashboardLiveSnapshotDto
|
|
taskStore.board = snapshot.board
|
|
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
|
notificationStore.notifications = snapshot.notifications.notifications
|
|
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
|
this.lastSequence = snapshot.cursor.sequence
|
|
this.connected = true
|
|
this.mode = 'live'
|
|
this.reconnectAttempts = 0
|
|
taskStore.stopBoardPolling()
|
|
return
|
|
}
|
|
|
|
const eventDto = data as DashboardLiveEventDto
|
|
this.applyEnvelope(eventDto.envelope, forUser)
|
|
this.lastSequence = eventDto.cursor.sequence
|
|
this.connected = true
|
|
this.mode = 'live'
|
|
this.reconnectAttempts = 0
|
|
taskStore.stopBoardPolling()
|
|
}, { 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) {
|
|
console.warn('[liveSync] stream failed', error)
|
|
this.error = 'Live updates unavailable'
|
|
}
|
|
} finally {
|
|
this.connecting = false
|
|
}
|
|
|
|
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
|
|
}
|
|
},
|
|
|
|
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
|
const taskStore = useTaskStore()
|
|
const notificationStore = useNotificationStore()
|
|
|
|
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
|
taskStore.board = envelope.payload
|
|
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
|
return
|
|
}
|
|
|
|
if (envelope.type === 'notifications.snapshot') {
|
|
const snapshot = envelope.payload as NotificationSnapshotDto
|
|
if (snapshot.forUser !== forUser) return
|
|
notificationStore.notifications = snapshot.notifications
|
|
notificationStore.unreadCount = snapshot.unreadCount
|
|
}
|
|
},
|
|
|
|
disconnect() {
|
|
this.stopWatchdog()
|
|
this.controller?.abort()
|
|
this.controller = null
|
|
this.connected = false
|
|
this.connecting = false
|
|
this.mode = 'polling'
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
},
|
|
|
|
scheduleReconnect(forUser = 'bao') {
|
|
if (this.reconnectTimer) return
|
|
// 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
|
|
this.connect(forUser)
|
|
}, delay)
|
|
},
|
|
},
|
|
})
|