From 5df51946511935fa913d8b38d55a6b1a33a75130 Mon Sep 17 00:00:00 2001 From: DevOps Date: Mon, 22 Jun 2026 20:43:18 +0200 Subject: [PATCH] fix: deploy GatewayBridge + Dashboard SSE live endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/stores/liveSync.ts | 172 +++++++++++++++++++++ frontend/src/views/Dashboard/FlowBoard.vue | 2 +- frontend/src/views/NotificationsView.vue | 2 +- frontend/src/views/TaskBoardView.vue | 2 +- 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 frontend/src/stores/liveSync.ts diff --git a/frontend/src/stores/liveSync.ts b/frontend/src/stores/liveSync.ts new file mode 100644 index 0000000..0cbedfd --- /dev/null +++ b/frontend/src/stores/liveSync.ts @@ -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 | 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) + }, + }, +}) diff --git a/frontend/src/views/Dashboard/FlowBoard.vue b/frontend/src/views/Dashboard/FlowBoard.vue index e7b6155..cb02c03 100644 --- a/frontend/src/views/Dashboard/FlowBoard.vue +++ b/frontend/src/views/Dashboard/FlowBoard.vue @@ -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' diff --git a/frontend/src/views/NotificationsView.vue b/frontend/src/views/NotificationsView.vue index a6ca6c7..950d31c 100644 --- a/frontend/src/views/NotificationsView.vue +++ b/frontend/src/views/NotificationsView.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() diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index c6fb935..0f54cc8 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -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[number]