Files
awoooi/apps/web/src/stores/timeline.store.ts
OG T 196d269b92 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>
2026-03-22 18:57:44 +08:00

265 lines
8.3 KiB
TypeScript

'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)