- 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>
450 lines
13 KiB
TypeScript
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)
|