- 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>
265 lines
8.3 KiB
TypeScript
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)
|