fix: deploy GatewayBridge + Dashboard SSE live endpoints
Root cause: /api/bridge/health and /api/dashboard/live returned 404 live because the source files existed on disk but were never committed to git. The CD pipeline deploys from committed main, so the deployed containers lacked these endpoints. Changes: - Add GatewayBridgeController with /api/bridge/health endpoint - Add DashboardController Live() SSE endpoint for /api/dashboard/live - Add LiveUpdateService (in-memory pub/sub for SSE updates) - Add TaskBridgeService (structured agent-to-backend bridge) - Add nginx routing blocks for /api/bridge/ and /api/dashboard/live - Add host-level nginx-nexus.conf blocks for bridge + live pass-through - Update ServiceCollectionExtensions with DI registrations - Update Dashboard.cs model with SSE-related DTOs Verification after deploy: curl https://nexus.noveria.net/api/bridge/health → 200 JSON curl https://nexus.noveria.net/api/dashboard/live → 200 SSE stream
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
}),
|
||||
|
||||
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.controller = new AbortController()
|
||||
|
||||
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
|
||||
} 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)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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.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
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect(forUser)
|
||||
}, delay)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import { useAgentStore } from '../../stores/agents'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useDashboardStore } from '../../stores/dashboard'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { useLiveSyncStore } from '../../stores/live-sync'
|
||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useLiveSyncStore } from '../stores/live-sync'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||
|
||||
const store = useNotificationStore()
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTaskStore } from '../stores/tasks'
|
||||
import { useLiveSyncStore } from '../stores/live-sync'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
|
||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user