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:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View 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

View 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,
}
}

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

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

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