feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
279
apps/web/src/stores/agent.store.ts
Normal file
279
apps/web/src/stores/agent.store.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Agent Store - OpenClaw 狀態管理 (企業級強化版)
|
||||
* ADR-004: Zustand 狀態管理
|
||||
*
|
||||
* 封裝內容:
|
||||
* - Agent 狀態 (idle/thinking/executing/waiting_approval)
|
||||
* - 思考串流 (thinkingStream)
|
||||
* - SSE 連線邏輯 (含 AbortController + Buffer)
|
||||
* - 錯誤處理
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'waiting_approval' | 'error'
|
||||
|
||||
export interface ThinkingStep {
|
||||
type: 'thinking' | 'result' | 'error' | 'graph_rag' | 'finops'
|
||||
content: string
|
||||
timestamp: Date
|
||||
// GraphRAG 結構化資料 (可選)
|
||||
graphData?: {
|
||||
analysisType: 'blast_radius' | 'root_cause'
|
||||
targetService: string
|
||||
affectedServices?: string[]
|
||||
dependencyChain?: string[]
|
||||
probableRootCauses?: string[]
|
||||
criticalPath?: string[]
|
||||
}
|
||||
// FinOps 結構化資料 (可選)
|
||||
finopsData?: {
|
||||
totalWastedUsd: number
|
||||
realizableSavingsUsd: number
|
||||
freedResourcesUsd: number
|
||||
topActions: Array<{
|
||||
action: string
|
||||
savings: number
|
||||
risk: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface Approval {
|
||||
id: string
|
||||
type: string
|
||||
action: {
|
||||
pluginId: string
|
||||
operation: string
|
||||
parameters: Record<string, unknown>
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
}
|
||||
requestedAt: Date
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
// ==================== State ====================
|
||||
status: AgentStatus
|
||||
currentTask: string | null
|
||||
thinkingStream: ThinkingStep[]
|
||||
pendingApprovals: Approval[]
|
||||
conversationId: string | null
|
||||
error: string | null
|
||||
|
||||
// SSE 連線控制 (內部使用)
|
||||
_abortController: AbortController | null
|
||||
|
||||
// ==================== Actions ====================
|
||||
setStatus: (status: AgentStatus) => void
|
||||
setCurrentTask: (task: string | null) => void
|
||||
setError: (error: string | null) => void
|
||||
|
||||
// Thinking Stream
|
||||
appendThinking: (step: ThinkingStep) => void
|
||||
clearThinking: () => void
|
||||
|
||||
// SSE 串流控制
|
||||
startThinkingStream: (apiUrl?: string) => Promise<void>
|
||||
stopThinkingStream: () => void
|
||||
|
||||
// Approvals
|
||||
addApproval: (approval: Approval) => void
|
||||
removeApproval: (approvalId: string) => void
|
||||
|
||||
// Conversation
|
||||
setConversationId: (id: string | null) => void
|
||||
|
||||
// Reset
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return `${url}/api/v1`
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
const initialState = {
|
||||
status: 'idle' as AgentStatus,
|
||||
currentTask: null,
|
||||
thinkingStream: [],
|
||||
pendingApprovals: [],
|
||||
conversationId: null,
|
||||
error: null,
|
||||
_abortController: null,
|
||||
}
|
||||
|
||||
// ==================== Store ====================
|
||||
|
||||
export const useAgentStore = create<AgentState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ==================== Basic Setters ====================
|
||||
|
||||
setStatus: (status) => set({ status, error: status === 'error' ? get().error : null }),
|
||||
|
||||
setCurrentTask: (task) => set({ currentTask: task }),
|
||||
|
||||
setError: (error) => set({ error, status: error ? 'error' : get().status }),
|
||||
|
||||
// ==================== Thinking Stream ====================
|
||||
|
||||
appendThinking: (step) =>
|
||||
set((state) => ({
|
||||
thinkingStream: [...state.thinkingStream, step],
|
||||
})),
|
||||
|
||||
clearThinking: () => set({ thinkingStream: [] }),
|
||||
|
||||
// ==================== SSE Stream Control ====================
|
||||
|
||||
startThinkingStream: async (apiUrl?: string) => {
|
||||
const state = get()
|
||||
|
||||
// 中斷前一次未完成的請求
|
||||
state._abortController?.abort()
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
set({
|
||||
_abortController: abortController,
|
||||
status: 'thinking',
|
||||
error: null,
|
||||
thinkingStream: [],
|
||||
})
|
||||
|
||||
try {
|
||||
const url = apiUrl || `${API_BASE_URL}/agent/thinking`
|
||||
const response = await fetch(url, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('無法建立串流通道')
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = '' // Buffer 累積,防止 TCP 封包切斷 JSON
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// SSE 規範: 事件之間以 \n\n 分隔
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || '' // 保留不完整片段
|
||||
|
||||
for (const event of events) {
|
||||
if (event.startsWith('data: ')) {
|
||||
const data = event.slice(6).trim()
|
||||
|
||||
// 結束標記
|
||||
if (data === '[DONE]') {
|
||||
set({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
|
||||
// 安全解析 JSON
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { type: string; content: string }
|
||||
get().appendThinking({
|
||||
type: parsed.type as ThinkingStep['type'],
|
||||
content: parsed.content,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('JSON 解析錯誤,跳過片段:', data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({ status: 'idle' })
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
console.log('SSE 串流已手動中斷')
|
||||
set({ status: 'idle' })
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : '未知錯誤'
|
||||
set({
|
||||
status: 'error',
|
||||
error: message,
|
||||
})
|
||||
get().appendThinking({
|
||||
type: 'error',
|
||||
content: message,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
stopThinkingStream: () => {
|
||||
const state = get()
|
||||
state._abortController?.abort()
|
||||
set({
|
||||
_abortController: null,
|
||||
status: 'idle',
|
||||
})
|
||||
},
|
||||
|
||||
// ==================== Approvals ====================
|
||||
|
||||
addApproval: (approval) =>
|
||||
set((state) => ({
|
||||
pendingApprovals: [...state.pendingApprovals, approval],
|
||||
status: 'waiting_approval',
|
||||
})),
|
||||
|
||||
removeApproval: (approvalId) =>
|
||||
set((state) => {
|
||||
const pendingApprovals = state.pendingApprovals.filter(
|
||||
(a) => a.id !== approvalId
|
||||
)
|
||||
return {
|
||||
pendingApprovals,
|
||||
status: pendingApprovals.length === 0 ? 'idle' : 'waiting_approval',
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== Conversation ====================
|
||||
|
||||
setConversationId: (id) => set({ conversationId: id }),
|
||||
|
||||
// ==================== Reset ====================
|
||||
|
||||
reset: () => {
|
||||
get()._abortController?.abort()
|
||||
set(initialState)
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
// ==================== Selectors (效能優化) ====================
|
||||
|
||||
export const selectAgentStatus = (state: AgentState) => state.status
|
||||
export const selectThinkingStream = (state: AgentState) => state.thinkingStream
|
||||
export const selectIsThinking = (state: AgentState) => state.status === 'thinking'
|
||||
export const selectHasError = (state: AgentState) => state.status === 'error'
|
||||
export const selectError = (state: AgentState) => state.error
|
||||
384
apps/web/src/stores/approval.store.ts
Normal file
384
apps/web/src/stores/approval.store.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Approval Store - HITL 授權狀態管理
|
||||
* ===================================
|
||||
* CISO-101: Multi-Sig 信任鏈前端整合
|
||||
*
|
||||
* Features:
|
||||
* - 輪詢 GET /api/v1/approvals/pending
|
||||
* - 簽核 POST /api/v1/approvals/{id}/sign
|
||||
* - 拒絕 POST /api/v1/approvals/{id}/reject
|
||||
* - Multi-Sig UX 狀態管理
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
|
||||
// =============================================================================
|
||||
// Types (與後端 Pydantic 模型對應)
|
||||
// =============================================================================
|
||||
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired'
|
||||
export type RiskLevel = 'low' | 'medium' | 'critical'
|
||||
export type DataImpact = 'none' | 'read_only' | 'write' | 'destructive'
|
||||
|
||||
export interface BlastRadius {
|
||||
affected_pods: number
|
||||
estimated_downtime: string
|
||||
related_services: string[]
|
||||
data_impact: DataImpact
|
||||
}
|
||||
|
||||
export interface DryRunCheck {
|
||||
name: string
|
||||
passed: boolean
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface Signature {
|
||||
id: string
|
||||
signer_id: string
|
||||
signer_name: string
|
||||
signed_at: string
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string
|
||||
action: string
|
||||
description: string
|
||||
status: ApprovalStatus
|
||||
risk_level: RiskLevel
|
||||
blast_radius: BlastRadius
|
||||
dry_run_checks: DryRunCheck[]
|
||||
required_signatures: number
|
||||
current_signatures: number
|
||||
signatures: Signature[]
|
||||
requested_by: string
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
resolved_at: string | null
|
||||
// 戰略 B: 告警風暴收斂
|
||||
fingerprint?: string | null
|
||||
hit_count?: number
|
||||
last_seen_at?: string | null
|
||||
}
|
||||
|
||||
export interface SignResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
approval: ApprovalRequest
|
||||
execution_triggered: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Store State
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalState {
|
||||
// Data
|
||||
pendingApprovals: ApprovalRequest[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
lastFetched: Date | null
|
||||
|
||||
// Signing state
|
||||
signingId: string | null
|
||||
rejectingId: string | null
|
||||
|
||||
// Recently resolved (for animation)
|
||||
recentlyApproved: Set<string>
|
||||
recentlyRejected: Set<string>
|
||||
|
||||
// Polling
|
||||
pollingInterval: number | null
|
||||
|
||||
// Actions
|
||||
fetchPending: () => Promise<void>
|
||||
signApproval: (id: string, signerId: string, signerName: string, comment?: string) => Promise<SignResponse | null>
|
||||
rejectApproval: (id: string, rejectorId: string, rejectorName: string, reason: string) => Promise<boolean>
|
||||
startPolling: (intervalMs?: number) => void
|
||||
stopPolling: () => void
|
||||
clearRecentlyResolved: (id: string) => void
|
||||
setError: (error: string | null) => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_POLLING_INTERVAL = 5000 // 5 seconds
|
||||
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
// =============================================================================
|
||||
// Store Implementation
|
||||
// =============================================================================
|
||||
|
||||
let pollingTimer: NodeJS.Timeout | null = null
|
||||
|
||||
export const useApprovalStore = create<ApprovalState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// Initial state
|
||||
pendingApprovals: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
signingId: null,
|
||||
rejectingId: null,
|
||||
recentlyApproved: new Set(),
|
||||
recentlyRejected: new Set(),
|
||||
pollingInterval: null,
|
||||
|
||||
// ==========================================================================
|
||||
// Fetch Pending Approvals
|
||||
// ==========================================================================
|
||||
fetchPending: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/approvals/pending`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const approvals: ApprovalRequest[] = data.approvals || []
|
||||
|
||||
console.log('[Approval] Fetched pending:', approvals.length)
|
||||
|
||||
set({
|
||||
pendingApprovals: approvals,
|
||||
isLoading: false,
|
||||
lastFetched: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Approval] Fetch failed:', err)
|
||||
set({
|
||||
isLoading: false,
|
||||
error: `Failed to fetch approvals: ${err}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Sign Approval
|
||||
// ==========================================================================
|
||||
signApproval: async (id, signerId, signerName, comment) => {
|
||||
set({ signingId: id, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/sign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
signer_id: signerId,
|
||||
signer_name: signerName,
|
||||
comment: comment || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result: SignResponse = await response.json()
|
||||
|
||||
console.log('[Approval] Signed:', id, result.message)
|
||||
|
||||
// Update local state
|
||||
set((state) => {
|
||||
const updatedApprovals = state.pendingApprovals.map((a) =>
|
||||
a.id === id ? result.approval : a
|
||||
)
|
||||
|
||||
// If approved, mark for removal animation
|
||||
const newRecentlyApproved = new Set(state.recentlyApproved)
|
||||
if (result.approval.status === 'approved') {
|
||||
newRecentlyApproved.add(id)
|
||||
}
|
||||
|
||||
return {
|
||||
pendingApprovals: result.approval.status === 'approved'
|
||||
? updatedApprovals.filter((a) => a.id !== id)
|
||||
: updatedApprovals,
|
||||
signingId: null,
|
||||
recentlyApproved: newRecentlyApproved,
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('[Approval] Sign failed:', err)
|
||||
set({
|
||||
signingId: null,
|
||||
error: `Sign failed: ${err}`,
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Reject Approval
|
||||
// ==========================================================================
|
||||
rejectApproval: async (id, rejectorId, rejectorName, reason) => {
|
||||
set({ rejectingId: id, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rejector_id: rejectorId,
|
||||
rejector_name: rejectorName,
|
||||
reason,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[Approval] Rejected:', id)
|
||||
|
||||
// Update local state
|
||||
set((state) => {
|
||||
const newRecentlyRejected = new Set(state.recentlyRejected)
|
||||
newRecentlyRejected.add(id)
|
||||
|
||||
return {
|
||||
pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id),
|
||||
rejectingId: null,
|
||||
recentlyRejected: newRecentlyRejected,
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[Approval] Reject failed:', err)
|
||||
set({
|
||||
rejectingId: null,
|
||||
error: `Reject failed: ${err}`,
|
||||
})
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Polling
|
||||
// ==========================================================================
|
||||
startPolling: (intervalMs = DEFAULT_POLLING_INTERVAL) => {
|
||||
const state = get()
|
||||
|
||||
// Clear existing timer
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer)
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
state.fetchPending()
|
||||
|
||||
// Start polling
|
||||
pollingTimer = setInterval(() => {
|
||||
get().fetchPending()
|
||||
}, intervalMs)
|
||||
|
||||
set({ pollingInterval: intervalMs })
|
||||
console.log('[Approval] Polling started:', intervalMs, 'ms')
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer)
|
||||
pollingTimer = null
|
||||
}
|
||||
set({ pollingInterval: null })
|
||||
console.log('[Approval] Polling stopped')
|
||||
},
|
||||
|
||||
clearRecentlyResolved: (id) => {
|
||||
set((state) => {
|
||||
const newApproved = new Set(state.recentlyApproved)
|
||||
const newRejected = new Set(state.recentlyRejected)
|
||||
newApproved.delete(id)
|
||||
newRejected.delete(id)
|
||||
return {
|
||||
recentlyApproved: newApproved,
|
||||
recentlyRejected: newRejected,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setError: (error) => {
|
||||
set({ error })
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Selector Hooks
|
||||
// =============================================================================
|
||||
|
||||
export const usePendingApprovals = () =>
|
||||
useApprovalStore((state) => state.pendingApprovals)
|
||||
|
||||
export const useApprovalCount = () =>
|
||||
useApprovalStore((state) => state.pendingApprovals.length)
|
||||
|
||||
export const useApprovalById = (id: string) =>
|
||||
useApprovalStore((state) => state.pendingApprovals.find((a) => a.id === id))
|
||||
|
||||
export const useIsSigningApproval = (id: string) =>
|
||||
useApprovalStore((state) => state.signingId === id)
|
||||
|
||||
export const useIsRejectingApproval = (id: string) =>
|
||||
useApprovalStore((state) => state.rejectingId === id)
|
||||
|
||||
export const useApprovalError = () =>
|
||||
useApprovalStore((state) => state.error)
|
||||
|
||||
// =============================================================================
|
||||
// Type Converters (Backend → Frontend)
|
||||
// =============================================================================
|
||||
|
||||
export function toFrontendApproval(backend: ApprovalRequest): import('@/components/approval').ApprovalRequest {
|
||||
return {
|
||||
id: backend.id,
|
||||
action: backend.action,
|
||||
description: backend.description,
|
||||
riskLevel: backend.risk_level === 'critical' ? 'critical' :
|
||||
backend.risk_level === 'medium' ? 'medium' : 'low',
|
||||
blastRadius: {
|
||||
affectedPods: backend.blast_radius.affected_pods,
|
||||
estimatedDowntime: backend.blast_radius.estimated_downtime,
|
||||
relatedServices: backend.blast_radius.related_services,
|
||||
dataImpact: backend.blast_radius.data_impact.toUpperCase() as 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE',
|
||||
},
|
||||
dryRunChecks: backend.dry_run_checks.map((c) => ({
|
||||
name: c.name,
|
||||
passed: c.passed,
|
||||
message: c.message || undefined,
|
||||
})),
|
||||
requiredSignatures: backend.required_signatures,
|
||||
currentSignatures: backend.current_signatures,
|
||||
requestedBy: backend.requested_by,
|
||||
requestedAt: new Date(backend.created_at).toLocaleString('zh-TW'),
|
||||
// 戰略 B: 告警風暴收斂
|
||||
hitCount: backend.hit_count ?? 1,
|
||||
lastSeenAt: backend.last_seen_at || undefined,
|
||||
fingerprint: backend.fingerprint || undefined,
|
||||
}
|
||||
}
|
||||
449
apps/web/src/stores/dashboard.store.ts
Normal file
449
apps/web/src/stores/dashboard.store.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 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)
|
||||
21
apps/web/src/stores/index.ts
Normal file
21
apps/web/src/stores/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export * from './agent.store'
|
||||
export * from './dashboard.store'
|
||||
|
||||
// Re-export selectors for convenience
|
||||
export {
|
||||
selectAgentStatus,
|
||||
selectThinkingStream,
|
||||
selectIsThinking,
|
||||
selectHasError,
|
||||
selectError,
|
||||
} from './agent.store'
|
||||
|
||||
export {
|
||||
useConnectionStatus,
|
||||
useHosts,
|
||||
useHostByIp,
|
||||
useOverallStatus,
|
||||
useAlerts,
|
||||
useMockMode,
|
||||
useLastUpdate,
|
||||
} from './dashboard.store'
|
||||
264
apps/web/src/stores/timeline.store.ts
Normal file
264
apps/web/src/stores/timeline.store.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Timeline Store - 行動日誌狀態管理 (Phase 4 企業級 + 資安修補)
|
||||
* =============================================================
|
||||
* MVP 最終閉環 - 完整稽核軌跡
|
||||
*
|
||||
* 資安設計:
|
||||
* - 事件來源: 後端 API (GET /api/v1/timeline/events)
|
||||
* - 防止偽造: 前端不可直接新增事件,必須透過 API 回調
|
||||
* - OOM 防護: maxEvents=100 限制記憶體使用
|
||||
*
|
||||
* 事件類型對應統帥規格:
|
||||
* - [SYSTEM] 系統接收告警
|
||||
* - [AGENT] OpenClaw AI 分析完成
|
||||
* - [SECURITY] 權限阻擋 (Access Denied)
|
||||
* - [HUMAN] 人類授權簽核
|
||||
* - [EXEC] 執行完成
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
// =============================================================================
|
||||
// API Configuration (統帥鐵律: 禁止 hardcode localhost)
|
||||
// =============================================================================
|
||||
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
// =============================================================================
|
||||
// Types (統帥規格)
|
||||
// =============================================================================
|
||||
|
||||
export type TimelineEventType =
|
||||
| 'system' // [SYSTEM] 系統告警接收
|
||||
| 'agent' // [AGENT] OpenClaw AI 分析
|
||||
| 'security' // [SECURITY] 權限阻擋
|
||||
| 'human' // [HUMAN] 人類授權
|
||||
| 'exec' // [EXEC] 執行完成
|
||||
|
||||
export type TimelineEventStatus = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string
|
||||
type: TimelineEventType
|
||||
status: TimelineEventStatus
|
||||
timestamp: Date
|
||||
title: string
|
||||
description?: string
|
||||
actor?: string
|
||||
actorRole?: string
|
||||
riskLevel?: 'low' | 'medium' | 'high' | 'critical'
|
||||
approvalId?: string
|
||||
}
|
||||
|
||||
// Backend API response format
|
||||
interface BackendTimelineEvent {
|
||||
id: number
|
||||
type: string
|
||||
status: string
|
||||
title: string
|
||||
description?: string | null
|
||||
actor?: string | null
|
||||
actor_role?: string | null
|
||||
risk_level?: string | null
|
||||
approval_id?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface TimelineState {
|
||||
events: TimelineEvent[]
|
||||
maxEvents: number
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
// Smart Polling 狀態
|
||||
isSmartPolling: boolean
|
||||
newestExecEventId: string | null
|
||||
|
||||
// Actions
|
||||
fetchEvents: () => Promise<void>
|
||||
/** Smart Polling: 每秒輪詢直到看到 EXEC 事件 (最多 5 秒) */
|
||||
startSmartPolling: () => void
|
||||
stopSmartPolling: () => void
|
||||
/** @internal 僅供 API 回調使用,勿從 Console 呼叫 */
|
||||
_addEventFromBackend: (event: BackendTimelineEvent) => void
|
||||
/** @internal 僅供測試使用 */
|
||||
addEvent: (event: Omit<TimelineEvent, 'id' | 'timestamp'>) => void
|
||||
clearEvents: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function transformBackendEvent(e: BackendTimelineEvent): TimelineEvent {
|
||||
return {
|
||||
id: `evt-${e.id}`,
|
||||
type: e.type as TimelineEventType,
|
||||
status: e.status as TimelineEventStatus,
|
||||
timestamp: new Date(e.created_at),
|
||||
title: e.title,
|
||||
description: e.description ?? undefined,
|
||||
actor: e.actor ?? undefined,
|
||||
actorRole: e.actor_role ?? undefined,
|
||||
riskLevel: e.risk_level as TimelineEvent['riskLevel'] ?? undefined,
|
||||
approvalId: e.approval_id ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Store
|
||||
// =============================================================================
|
||||
|
||||
// Smart Polling Timer Reference (module-level for cleanup)
|
||||
let smartPollingTimer: NodeJS.Timeout | null = null
|
||||
let smartPollingCount = 0
|
||||
const SMART_POLLING_MAX_ATTEMPTS = 5
|
||||
|
||||
export const useTimelineStore = create<TimelineState>((set, get) => ({
|
||||
events: [],
|
||||
maxEvents: 100, // 最多保留 100 筆 (防止 OOM)
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isSmartPolling: false,
|
||||
newestExecEventId: null,
|
||||
|
||||
fetchEvents: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/timeline/events?limit=100`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const events = (data.events as BackendTimelineEvent[]).map(transformBackendEvent)
|
||||
|
||||
set({
|
||||
events: events.slice(0, get().maxEvents),
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
console.log('[Timeline] Fetched from backend:', events.length, 'events')
|
||||
|
||||
// Smart Polling: 檢查是否有新的 EXEC 事件
|
||||
if (get().isSmartPolling) {
|
||||
const execEvents = events.filter(e => e.type === 'exec')
|
||||
if (execEvents.length > 0) {
|
||||
const newestExec = execEvents[0]
|
||||
const currentNewest = get().newestExecEventId
|
||||
|
||||
// 如果發現新的 EXEC 事件,停止輪詢並標記
|
||||
if (newestExec.id !== currentNewest) {
|
||||
console.log('[Timeline] Smart Polling: 發現新 EXEC 事件!', newestExec.id)
|
||||
set({ newestExecEventId: newestExec.id })
|
||||
get().stopSmartPolling()
|
||||
|
||||
// 3 秒後清除 newestExecEventId (結束閃爍效果)
|
||||
setTimeout(() => {
|
||||
set({ newestExecEventId: null })
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Timeline] Fetch failed:', err)
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
startSmartPolling: () => {
|
||||
// 停止任何現有的輪詢
|
||||
if (smartPollingTimer) {
|
||||
clearInterval(smartPollingTimer)
|
||||
}
|
||||
|
||||
smartPollingCount = 0
|
||||
set({ isSmartPolling: true })
|
||||
console.log('[Timeline] Smart Polling 啟動: 每秒檢查 EXEC 事件...')
|
||||
|
||||
// 立即執行一次
|
||||
get().fetchEvents()
|
||||
|
||||
// 每秒輪詢
|
||||
smartPollingTimer = setInterval(() => {
|
||||
smartPollingCount++
|
||||
|
||||
if (smartPollingCount >= SMART_POLLING_MAX_ATTEMPTS) {
|
||||
console.log('[Timeline] Smart Polling 達到最大次數,停止')
|
||||
get().stopSmartPolling()
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[Timeline] Smart Polling #${smartPollingCount}...`)
|
||||
get().fetchEvents()
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
stopSmartPolling: () => {
|
||||
if (smartPollingTimer) {
|
||||
clearInterval(smartPollingTimer)
|
||||
smartPollingTimer = null
|
||||
}
|
||||
smartPollingCount = 0
|
||||
set({ isSmartPolling: false })
|
||||
console.log('[Timeline] Smart Polling 停止')
|
||||
},
|
||||
|
||||
_addEventFromBackend: (backendEvent) => {
|
||||
const newEvent = transformBackendEvent(backendEvent)
|
||||
|
||||
set((state) => ({
|
||||
events: [newEvent, ...state.events].slice(0, state.maxEvents),
|
||||
}))
|
||||
|
||||
console.log('[Timeline] Event from backend:', newEvent.type.toUpperCase(), newEvent.title)
|
||||
},
|
||||
|
||||
addEvent: (eventData) => {
|
||||
// 警告: 此方法僅供測試,正式環境事件應來自後端
|
||||
console.warn('[Timeline] addEvent called directly - use backend API for production')
|
||||
|
||||
const newEvent: TimelineEvent = {
|
||||
...eventData,
|
||||
id: `evt-local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
events: [newEvent, ...state.events].slice(0, state.maxEvents),
|
||||
}))
|
||||
|
||||
console.log('[Timeline]', newEvent.type.toUpperCase(), newEvent.title)
|
||||
},
|
||||
|
||||
clearEvents: () => set({ events: [] }),
|
||||
}))
|
||||
|
||||
// =============================================================================
|
||||
// Selectors
|
||||
// =============================================================================
|
||||
|
||||
export const useTimelineEvents = () => useTimelineStore((state) => state.events)
|
||||
export const useTimelineLoading = () => useTimelineStore((state) => state.isLoading)
|
||||
export const useTimelineError = () => useTimelineStore((state) => state.error)
|
||||
export const useFetchTimelineEvents = () => useTimelineStore((state) => state.fetchEvents)
|
||||
export const useStartSmartPolling = () => useTimelineStore((state) => state.startSmartPolling)
|
||||
export const useNewestExecEventId = () => useTimelineStore((state) => state.newestExecEventId)
|
||||
export const useIsSmartPolling = () => useTimelineStore((state) => state.isSmartPolling)
|
||||
Reference in New Issue
Block a user