Files
awoooi/apps/web/src/stores/dashboard.store.ts
OG T 5ee139749a chore(lint): 清理 7 項 ESLint 警告
- useApprovalSSE.ts: 標記未使用的 fallbackToPolling
- useErrors.ts: 移除未使用的 ErrorListResponse import
- dashboard.store.ts: 標記 SSE event 參數
- agent.store.ts: 加註 SSE 串流迴圈說明
- approval.store.ts: 改用正規 type import
- terminal.store.ts: 改用 inline type import
- OmniTerminal.tsx: 改用 type import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 16:40:19 +08:00

450 lines
13 KiB
TypeScript

/**
* Dashboard Store - 戰情室狀態管理
* =================================
* Enterprise-grade SSE integration with Zustand
*
* Features:
* - EventSource SSE 連線管理
* - Hydration 模式 (snapshot + stream)
* - 自動重連機制 (exponential backoff)
* - Buffer 累積防止 JSON 切斷
* - 資源清理 (AbortController)
*
* ADR-004: Zustand 狀態管理
*/
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
// =============================================================================
// Types
// =============================================================================
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
export type HostStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unreachable'
export type ServiceStatus = 'up' | 'down' | 'degraded' | 'healthy' | 'warning' | 'critical' | 'thinking' | 'syncing' | 'idle'
export interface HostService {
name: string
status: ServiceStatus
port: number | null
latency_ms: number | null
error: string | null
}
export interface BaselineData {
baseline_value: number
std_deviation: number
sigma_deviation: number | null
window_hours?: number
}
export interface HostMetrics {
cpu_percent: number
memory_percent: number
disk_percent: number
load_avg_1m: number
uptime_hours: number
// Dynamic Baseline
cpu_baseline?: BaselineData | null
memory_baseline?: BaselineData | null
}
export interface Host {
ip: string
name: string
role: string
status: HostStatus
services: HostService[]
metrics: HostMetrics | null
last_check: string
}
export interface DashboardSnapshot {
timestamp: string
environment: string
mock_mode: boolean
overall_status: 'healthy' | 'degraded' | 'unhealthy'
hosts: Host[]
alerts_count: number
pending_approvals: number
}
export interface SSEEvent {
type: string
data: Record<string, unknown>
timestamp: string
event_id: string
}
// =============================================================================
// Store State
// =============================================================================
interface DashboardState {
// Connection
connectionStatus: ConnectionStatus
lastConnected: Date | null
reconnectAttempts: number
error: string | null
// Data
snapshot: DashboardSnapshot | null
hosts: Host[]
overallStatus: 'healthy' | 'degraded' | 'unhealthy'
alertsCount: number
pendingApprovals: number
lastUpdate: Date | null
mockMode: boolean
// SSE Events buffer
eventBuffer: SSEEvent[]
// Actions
connect: (apiBaseUrl: string) => void
disconnect: () => void
fetchSnapshot: (apiBaseUrl: string) => Promise<void>
applySnapshot: (snapshot: DashboardSnapshot) => void
applyHostUpdate: (data: Record<string, unknown>) => void
setConnectionStatus: (status: ConnectionStatus) => void
setError: (error: string | null) => void
reset: () => void
}
// =============================================================================
// Constants
// =============================================================================
const MAX_RECONNECT_ATTEMPTS = 10
const BASE_RECONNECT_DELAY = 1000 // 1 second
const MAX_RECONNECT_DELAY = 30000 // 30 seconds
const HEARTBEAT_TIMEOUT = 45000 // 45 seconds
// =============================================================================
// Store Implementation
// =============================================================================
// Store EventSource reference outside store to avoid serialization issues
let eventSource: EventSource | null = null
let reconnectTimeout: NodeJS.Timeout | null = null
let heartbeatTimeout: NodeJS.Timeout | null = null
export const useDashboardStore = create<DashboardState>()(
subscribeWithSelector((set, get) => ({
// Initial state
connectionStatus: 'disconnected',
lastConnected: null,
reconnectAttempts: 0,
error: null,
snapshot: null,
hosts: [],
overallStatus: 'healthy',
alertsCount: 0,
pendingApprovals: 0,
lastUpdate: null,
mockMode: false,
eventBuffer: [],
// ==========================================================================
// Actions
// ==========================================================================
connect: (apiBaseUrl: string) => {
const state = get()
// Already connected or connecting
if (eventSource && state.connectionStatus === 'connected') {
console.log('[SSE] Already connected')
return
}
// Clean up existing connection
if (eventSource) {
eventSource.close()
eventSource = null
}
// 統帥鐵律: 禁止任何 Fallback IP
const resolvedApiBaseUrl = apiBaseUrl ||
(typeof window !== 'undefined' ? process.env.NEXT_PUBLIC_API_URL : '')
if (!resolvedApiBaseUrl) {
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL. SSE will not connect.')
set({ connectionStatus: 'error', error: 'Missing API URL configuration' })
return
}
set({ connectionStatus: 'connecting', error: null })
console.log('[SSE] Connecting to', `${resolvedApiBaseUrl}/api/v1/dashboard/stream`)
// Create EventSource
eventSource = new EventSource(`${resolvedApiBaseUrl}/api/v1/dashboard/stream`)
// Reset heartbeat on any event
const resetHeartbeat = () => {
if (heartbeatTimeout) clearTimeout(heartbeatTimeout)
heartbeatTimeout = setTimeout(() => {
console.warn('[SSE] Heartbeat timeout, reconnecting...')
get().disconnect()
setTimeout(() => get().connect(resolvedApiBaseUrl), 1000)
}, HEARTBEAT_TIMEOUT)
}
// Connection opened
eventSource.onopen = () => {
console.log('[SSE] Connected')
set({
connectionStatus: 'connected',
lastConnected: new Date(),
reconnectAttempts: 0,
error: null,
})
resetHeartbeat()
// Hydration: Fetch snapshot after connection
get().fetchSnapshot(resolvedApiBaseUrl)
}
// Handle events
eventSource.addEventListener('connected', (_e: MessageEvent) => {
console.log('[SSE] Received connected event')
resetHeartbeat()
})
eventSource.addEventListener('host_update', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
console.log('[SSE] Host update:', data.overall_status)
get().applyHostUpdate(data)
resetHeartbeat()
} catch (err) {
console.error('[SSE] Failed to parse host_update:', err)
}
})
eventSource.addEventListener('heartbeat', (_e: MessageEvent) => {
console.log('[SSE] Heartbeat received')
resetHeartbeat()
})
eventSource.addEventListener('alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
console.log('[SSE] Alert:', data)
set((state) => ({
alertsCount: state.alertsCount + 1,
eventBuffer: [...state.eventBuffer.slice(-99), { type: 'alert', ...data }],
}))
resetHeartbeat()
} catch (err) {
console.error('[SSE] Failed to parse alert:', err)
}
})
// Error handling with exponential backoff
eventSource.onerror = (e) => {
console.error('[SSE] Error:', e)
const attempts = get().reconnectAttempts
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
set({
connectionStatus: 'error',
error: 'Max reconnection attempts reached',
})
eventSource?.close()
eventSource = null
return
}
set({
connectionStatus: 'reconnecting',
reconnectAttempts: attempts + 1,
})
// Exponential backoff
const delay = Math.min(
BASE_RECONNECT_DELAY * Math.pow(2, attempts),
MAX_RECONNECT_DELAY
)
console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${attempts + 1})`)
if (reconnectTimeout) clearTimeout(reconnectTimeout)
reconnectTimeout = setTimeout(() => {
eventSource?.close()
eventSource = null
get().connect(resolvedApiBaseUrl)
}, delay)
}
},
disconnect: () => {
console.log('[SSE] Disconnecting...')
if (eventSource) {
eventSource.close()
eventSource = null
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout)
heartbeatTimeout = null
}
set({
connectionStatus: 'disconnected',
reconnectAttempts: 0,
})
},
fetchSnapshot: async (apiBaseUrl: string) => {
// 統帥鐵律: 禁止任何 Fallback IP
const resolvedApiBaseUrl = apiBaseUrl ||
(typeof window !== 'undefined' ? process.env.NEXT_PUBLIC_API_URL : '')
if (!resolvedApiBaseUrl) {
console.error('[AWOOOI ERROR] Missing API URL for snapshot fetch')
return
}
try {
console.log('[SSE] Fetching snapshot for hydration from:', resolvedApiBaseUrl)
const response = await fetch(`${resolvedApiBaseUrl}/api/v1/dashboard/snapshot`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const snapshot: DashboardSnapshot = await response.json()
get().applySnapshot(snapshot)
console.log('[SSE] Snapshot applied, hosts:', snapshot.hosts.length)
} catch (err) {
console.error('[SSE] Failed to fetch snapshot:', err)
set({ error: `Snapshot fetch failed: ${err}` })
}
},
applySnapshot: (snapshot: DashboardSnapshot) => {
set({
snapshot,
hosts: snapshot.hosts,
overallStatus: snapshot.overall_status,
alertsCount: snapshot.alerts_count,
pendingApprovals: snapshot.pending_approvals,
lastUpdate: new Date(snapshot.timestamp),
mockMode: snapshot.mock_mode,
})
},
applyHostUpdate: (data: Record<string, unknown>) => {
const hostsData = data.hosts as Array<{
ip: string
name: string
status: HostStatus
metrics?: {
cpu_percent: number
memory_percent: number
cpu_baseline?: BaselineData | null
memory_baseline?: BaselineData | null
} | null
}>
if (!hostsData) return
set((state) => {
// Merge updates with existing hosts (including baseline data)
const updatedHosts = state.hosts.map((host) => {
const update = hostsData.find((h) => h.ip === host.ip)
if (update) {
return {
...host,
status: update.status,
metrics: update.metrics
? {
...host.metrics,
cpu_percent: update.metrics.cpu_percent,
memory_percent: update.metrics.memory_percent,
// Preserve baseline from snapshot (SSE updates don't include baseline)
cpu_baseline: update.metrics.cpu_baseline ?? host.metrics?.cpu_baseline,
memory_baseline: update.metrics.memory_baseline ?? host.metrics?.memory_baseline,
} as HostMetrics
: host.metrics,
}
}
return host
})
return {
hosts: updatedHosts,
overallStatus: (data.overall_status as 'healthy' | 'degraded' | 'unhealthy') || state.overallStatus,
lastUpdate: new Date(),
}
})
},
setConnectionStatus: (status: ConnectionStatus) => {
set({ connectionStatus: status })
},
setError: (error: string | null) => {
set({ error })
},
reset: () => {
get().disconnect()
set({
connectionStatus: 'disconnected',
lastConnected: null,
reconnectAttempts: 0,
error: null,
snapshot: null,
hosts: [],
overallStatus: 'healthy',
alertsCount: 0,
pendingApprovals: 0,
lastUpdate: null,
mockMode: false,
eventBuffer: [],
})
},
}))
)
// =============================================================================
// Selector Hooks
// =============================================================================
export const useConnectionStatus = () =>
useDashboardStore((state) => state.connectionStatus)
export const useConnectionError = () =>
useDashboardStore((state) => state.error)
export const useHosts = () =>
useDashboardStore((state) => state.hosts)
export const useHostByIp = (ip: string) =>
useDashboardStore((state) => state.hosts.find((h) => h.ip === ip))
export const useOverallStatus = () =>
useDashboardStore((state) => state.overallStatus)
export const useAlerts = () =>
useDashboardStore((state) => ({
count: state.alertsCount,
pending: state.pendingApprovals,
}))
export const useMockMode = () =>
useDashboardStore((state) => state.mockMode)
export const useLastUpdate = () =>
useDashboardStore((state) => state.lastUpdate)