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,5 @@
export * from './use-agent'
export * from './use-health'
export * from './useSSE'
export * from './useIncidents'
export * from './useGlobalPulseMetrics'

View File

@@ -0,0 +1,61 @@
/**
* Agent Hooks - OpenClaw 狀態與操作
*/
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
import { useAgentStore } from '@/stores/agent.store'
import { useEffect } from 'react'
/**
* 取得 Agent 狀態 (輪詢)
*/
export function useAgentStatus(pollInterval = 5000) {
const setStatus = useAgentStore((s) => s.setStatus)
const setCurrentTask = useAgentStore((s) => s.setCurrentTask)
const query = useQuery({
queryKey: ['agent', 'status'],
queryFn: () => apiClient.getAgentStatus(),
refetchInterval: pollInterval,
})
// Sync to Zustand store
useEffect(() => {
if (query.data) {
setStatus(query.data.status)
setCurrentTask(query.data.current_task)
}
}, [query.data, setStatus, setCurrentTask])
return query
}
/**
* 與 Agent 對話
*/
export function useAgentChat() {
const queryClient = useQueryClient()
const setStatus = useAgentStore((s) => s.setStatus)
const setConversationId = useAgentStore((s) => s.setConversationId)
const conversationId = useAgentStore((s) => s.conversationId)
return useMutation({
mutationFn: (message: string) =>
apiClient.chat(message, conversationId ?? undefined),
onMutate: () => {
setStatus('thinking')
},
onSuccess: (data) => {
setConversationId(data.conversation_id)
setStatus(data.requires_approval ? 'waiting_approval' : 'idle')
// Invalidate status query
queryClient.invalidateQueries({ queryKey: ['agent', 'status'] })
},
onError: () => {
setStatus('idle')
},
})
}

View File

@@ -0,0 +1,20 @@
/**
* Health Check Hook
*/
'use client'
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
/**
* 系統健康狀態
*/
export function useHealth() {
return useQuery({
queryKey: ['health'],
queryFn: () => apiClient.getHealth(),
refetchInterval: 30000, // 30 秒輪詢
retry: 3,
})
}

View File

@@ -0,0 +1,238 @@
'use client'
/**
* useGlobalPulseMetrics - 全局脈搏真實數據 Hook
* =============================================
* 統帥鐵律: 禁止假數據!所有指標必須來自 188 基地真實血脈
*
* Features:
* - Fail Fast 錯誤處理
* - 自動輪詢 (30 秒)
* - 誠實渲染 (無數據顯示 "--")
* - 統帥鐵律: 禁止 Hardcode API URL
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import type { PulseMetric } from '@/components/charts/global-pulse-chart'
// =============================================================================
// Types (對應後端 Response)
// =============================================================================
interface GoldMetricItem {
label: string
value: number | string
unit?: string
trend: number[]
status: 'healthy' | 'warning' | 'critical'
}
interface GoldMetricsResponse {
timestamp: string
service_name: string
metrics: GoldMetricItem[]
raw_data?: Record<string, unknown>
}
interface UseGlobalPulseMetricsOptions {
/** 輪詢間隔 (ms),預設 30000 */
pollInterval?: number
/** 是否啟用自動輪詢,預設 true */
enablePolling?: boolean
/** 服務名稱,預設 "awoooi-api" */
serviceName?: string
}
interface UseGlobalPulseMetricsResult {
/** 指標數據 (格式化給 GlobalPulseChart 使用) */
metrics: PulseMetric[]
/** 是否載入中 */
isLoading: boolean
/** 錯誤訊息 */
error: string | null
/** 最後更新時間 */
lastUpdated: Date | null
/** 手動刷新 */
refresh: () => Promise<void>
/** 是否有真實數據 (非全 0) */
hasRealData: boolean
}
// =============================================================================
// API Helper (統帥鐵律: 禁止 Hardcode)
// =============================================================================
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
}
// =============================================================================
// Hook Implementation
// =============================================================================
export function useGlobalPulseMetrics(
options: UseGlobalPulseMetricsOptions = {}
): UseGlobalPulseMetricsResult {
const {
pollInterval = 30000,
enablePolling = true,
serviceName = 'awoooi-api',
} = options
// State
const [metrics, setMetrics] = useState<PulseMetric[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const [hasRealData, setHasRealData] = useState(false)
// Polling timer ref
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
// ==========================================================================
// Fetch Metrics
// ==========================================================================
const fetchMetrics = useCallback(async () => {
const apiBaseUrl = getApiBaseUrl()
// Fail Fast: 無 API URL 則報錯
if (!apiBaseUrl) {
setError('NEXT_PUBLIC_API_URL 未設定')
setIsLoading(false)
setHasRealData(false)
// 誠實渲染: 顯示 "--"
setMetrics(createEmptyMetrics())
return
}
try {
setError(null)
const response = await fetch(
`${apiBaseUrl}/api/v1/metrics/gold?service_name=${encodeURIComponent(serviceName)}`,
{
headers: { 'Content-Type': 'application/json' },
// 10 秒超時
signal: AbortSignal.timeout(10000),
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data: GoldMetricsResponse = await response.json()
// 轉換為 PulseMetric 格式
const pulseMetrics: PulseMetric[] = data.metrics.map((m) => ({
label: m.label,
value: m.value,
unit: m.unit,
trend: m.trend,
status: m.status,
}))
setMetrics(pulseMetrics)
setLastUpdated(new Date())
// 檢查是否有真實數據 (非全 0)
const hasReal = pulseMetrics.some(
(m) => typeof m.value === 'number' && m.value > 0
)
setHasRealData(hasReal)
console.log('[GlobalPulse] Metrics fetched:', pulseMetrics.length, 'items')
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error('[GlobalPulse] Fetch error:', message)
setError(message)
setHasRealData(false)
// 誠實渲染: 錯誤時顯示 "--"
setMetrics(createEmptyMetrics())
} finally {
setIsLoading(false)
}
}, [serviceName])
// ==========================================================================
// Polling Effect
// ==========================================================================
useEffect(() => {
// Initial fetch
fetchMetrics()
// Start polling if enabled
if (enablePolling && pollInterval > 0) {
pollTimerRef.current = setInterval(fetchMetrics, pollInterval)
}
// Cleanup
return () => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
}, [fetchMetrics, enablePolling, pollInterval])
return {
metrics,
isLoading,
error,
lastUpdated,
refresh: fetchMetrics,
hasRealData,
}
}
// =============================================================================
// Helper: Create Empty Metrics (誠實渲染用)
// =============================================================================
function createEmptyMetrics(): PulseMetric[] {
return [
{
label: 'RPS',
value: '--',
unit: 'req/s',
trend: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
status: 'critical',
},
{
label: 'Error Rate',
value: '--',
unit: '%',
trend: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
status: 'critical',
},
{
label: 'P99 Latency',
value: '--',
unit: 'ms',
trend: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
status: 'critical',
},
{
label: 'AI Success',
value: '--',
unit: '%',
trend: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
status: 'critical',
},
]
}
// =============================================================================
// Export
// =============================================================================
export default useGlobalPulseMetrics

View File

@@ -0,0 +1,137 @@
'use client'
/**
* useIncidents - Phase 7 事件訂閱 Hook
* =====================================
*
* 功能:
* - 從 /api/v1/incidents 獲取活躍事件
* - 從 /api/v1/approvals/pending 獲取待簽核提案
* - 支援輪詢更新 (Smart Polling)
*
* 統帥鐵律: 禁止假數據!所有資料必須來自真實 API
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import {
apiClient,
type IncidentResponse,
type ApprovalResponse,
} from '@/lib/api-client'
// =============================================================================
// Types
// =============================================================================
interface UseIncidentsOptions {
/** 輪詢間隔 (毫秒)0 則不輪詢 */
pollInterval?: number
/** 是否啟用輪詢 */
enablePolling?: boolean
}
interface UseIncidentsResult {
/** 活躍事件清單 */
incidents: IncidentResponse[]
/** 待簽核提案清單 */
pendingApprovals: ApprovalResponse[]
/** 載入中狀態 */
isLoading: boolean
/** 錯誤訊息 */
error: string | null
/** 手動刷新 */
refresh: () => Promise<void>
/** 最後更新時間 */
lastUpdated: Date | null
}
// =============================================================================
// Hook
// =============================================================================
export function useIncidents(options: UseIncidentsOptions = {}): UseIncidentsResult {
const { pollInterval = 15000, enablePolling = true } = options
const [incidents, setIncidents] = useState<IncidentResponse[]>([])
const [pendingApprovals, setPendingApprovals] = useState<ApprovalResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const mountedRef = useRef(true)
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const fetchData = useCallback(async () => {
try {
// 並行請求 incidents 和 pending approvals
const [incidentsRes, approvalsRes] = await Promise.all([
apiClient.listIncidents(),
apiClient.getPendingApprovals(),
])
if (!mountedRef.current) return
setIncidents(incidentsRes.incidents)
setPendingApprovals(approvalsRes.approvals)
setError(null)
setLastUpdated(new Date())
} catch (err) {
if (!mountedRef.current) return
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
// eslint-disable-next-line no-console
console.error('[useIncidents] Fetch error:', message)
} finally {
if (mountedRef.current) {
setIsLoading(false)
}
}
}, [])
const refresh = useCallback(async () => {
setIsLoading(true)
await fetchData()
}, [fetchData])
// Initial fetch and polling
useEffect(() => {
mountedRef.current = true
fetchData()
if (enablePolling && pollInterval > 0) {
const poll = () => {
pollTimeoutRef.current = setTimeout(async () => {
if (mountedRef.current) {
await fetchData()
poll()
}
}, pollInterval)
}
poll()
}
return () => {
mountedRef.current = false
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current)
}
}
}, [fetchData, enablePolling, pollInterval])
return {
incidents,
pendingApprovals,
isLoading,
error,
refresh,
lastUpdated,
}
}
// =============================================================================
// Export
// =============================================================================
export type { UseIncidentsOptions, UseIncidentsResult }

View File

@@ -0,0 +1,149 @@
'use client'
/**
* useSSE - Server-Sent Events 連線 Hook
* =====================================
* Enterprise-grade SSE 封裝
*
* 特性:
* - 自動重連 (Exponential Backoff)
* - Heartbeat 超時偵測
* - AbortController 資源清理
* - 記憶體洩漏防禦 (cleanup)
*
* 絕對鐵律:
* - useEffect cleanup 必須呼叫 eventSource.close()
* - 否則頁面切換會產生殭屍連線
*/
import { useEffect, useRef, useCallback } from 'react'
import { useDashboardStore } from '@/stores/dashboard.store'
// =============================================================================
// Types
// =============================================================================
interface UseSSEOptions {
/** 自動連線 (預設 true) */
autoConnect?: boolean
/** API Base URL (覆蓋環境變數) */
apiBaseUrl?: string
}
interface UseSSEReturn {
/** 連線狀態 */
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
/** 是否已連線 */
isConnected: boolean
/** 是否重連中 */
isReconnecting: boolean
/** 錯誤訊息 */
error: string | null
/** 手動連線 */
connect: () => void
/** 手動斷線 */
disconnect: () => void
}
// =============================================================================
// Constants
// =============================================================================
const getApiBaseUrl = (): string => {
// Server-side rendering 時回傳空字串
if (typeof window === 'undefined') return ''
// 統帥鐵律: 禁止任何 Fallback IP
const url = process.env.NEXT_PUBLIC_API_URL
if (!url) {
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL. SSE will not connect.')
return ''
}
return url
}
// =============================================================================
// Hook
// =============================================================================
export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
const {
autoConnect = true,
apiBaseUrl: customApiBaseUrl,
} = options
// Zustand store actions
const connect = useDashboardStore((s) => s.connect)
const disconnect = useDashboardStore((s) => s.disconnect)
const connectionStatus = useDashboardStore((s) => s.connectionStatus)
const error = useDashboardStore((s) => s.error)
// Ref to track if mounted (防止 unmount 後更新 state)
const mountedRef = useRef(true)
// Resolve API URL
const apiBaseUrl = customApiBaseUrl ?? getApiBaseUrl()
// Connect handler
const handleConnect = useCallback(() => {
if (!mountedRef.current) return
console.log('[useSSE] Initiating connection to:', apiBaseUrl)
connect(apiBaseUrl)
}, [apiBaseUrl, connect])
// Disconnect handler
const handleDisconnect = useCallback(() => {
console.log('[useSSE] Disconnecting...')
disconnect()
}, [disconnect])
// ==========================================================================
// Effect: 自動連線 + 資源清理 (記憶體洩漏防禦)
// ==========================================================================
useEffect(() => {
mountedRef.current = true
if (autoConnect && apiBaseUrl) {
console.log('[useSSE] Auto-connecting...')
handleConnect()
}
// ⚠️ CRITICAL: 清理函數 - 防止殭屍連線
// 此處必須呼叫 disconnect() 關閉 EventSource
// 否則頁面切換時連線不會關閉,造成記憶體洩漏
return () => {
console.log('[useSSE] Cleanup: closing EventSource')
mountedRef.current = false
handleDisconnect()
}
}, [autoConnect, apiBaseUrl, handleConnect, handleDisconnect])
return {
connectionStatus,
isConnected: connectionStatus === 'connected',
isReconnecting: connectionStatus === 'reconnecting',
error,
connect: handleConnect,
disconnect: handleDisconnect,
}
}
// =============================================================================
// Utility Hook: SSE 連線狀態顯示
// =============================================================================
export function useSSEStatus() {
const connectionStatus = useDashboardStore((s) => s.connectionStatus)
const reconnectAttempts = useDashboardStore((s) => s.reconnectAttempts)
const lastConnected = useDashboardStore((s) => s.lastConnected)
const error = useDashboardStore((s) => s.error)
return {
status: connectionStatus,
attempts: reconnectAttempts,
lastConnected,
error,
isHealthy: connectionStatus === 'connected',
needsAttention: connectionStatus === 'error' || connectionStatus === 'reconnecting',
}
}