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:
5
apps/web/src/hooks/index.ts
Normal file
5
apps/web/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './use-agent'
|
||||
export * from './use-health'
|
||||
export * from './useSSE'
|
||||
export * from './useIncidents'
|
||||
export * from './useGlobalPulseMetrics'
|
||||
61
apps/web/src/hooks/use-agent.ts
Normal file
61
apps/web/src/hooks/use-agent.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
}
|
||||
20
apps/web/src/hooks/use-health.ts
Normal file
20
apps/web/src/hooks/use-health.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
238
apps/web/src/hooks/useGlobalPulseMetrics.ts
Normal file
238
apps/web/src/hooks/useGlobalPulseMetrics.ts
Normal 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
|
||||
137
apps/web/src/hooks/useIncidents.ts
Normal file
137
apps/web/src/hooks/useIncidents.ts
Normal 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 }
|
||||
149
apps/web/src/hooks/useSSE.ts
Normal file
149
apps/web/src/hooks/useSSE.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user