diff --git a/apps/web/src/components/topology/ServiceTopology.tsx b/apps/web/src/components/topology/ServiceTopology.tsx new file mode 100644 index 00000000..ece151b9 --- /dev/null +++ b/apps/web/src/components/topology/ServiceTopology.tsx @@ -0,0 +1,140 @@ +'use client' + +/** + * ServiceTopology — React Flow 嵌套群組拓撲圖主元件 + * ================================================== + * Sprint 5 Phase 2: 串接真實 dashboard API + * + * 功能: + * - 從 useDashboardStore 讀取真實主機/服務資料 + * - React Flow 渲染嵌套群組 + 服務節點 + 依賴邊線 + * - 控制列: 全部展開/收合/只看異常 + * - MiniMap + Controls + * + * 零假數據: 所有資料來自 HostAggregator 真實探測 + * + * 建立時間: 2026-04-08 (台北時區) + * 建立者: Claude Code (Sprint 5 Phase 2) + */ + +import { useCallback, useMemo } from 'react' +import { + ReactFlow, + Controls, + MiniMap, + Background, + BackgroundVariant, + type NodeTypes, + type EdgeTypes, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' + +import { useTopologyData } from './hooks/useTopologyData' +import { ServiceNode } from './nodes/ServiceNode' +import { GroupNode } from './nodes/GroupNode' +import { TopologyEdge } from './edges/TopologyEdge' + +// ============================================================================= +// Node/Edge Types (React Flow 需要在元件外定義,避免每次 render 重建) +// ============================================================================= + +const nodeTypes: NodeTypes = { + serviceNode: ServiceNode, + groupNode: GroupNode, +} + +const edgeTypes: EdgeTypes = { + topologyEdge: TopologyEdge, +} + +// ============================================================================= +// Props +// ============================================================================= + +export interface ServiceTopologyProps { + /** 顯示模式: compact=收合群組, full=可展開 */ + mode?: 'compact' | 'full' + /** 顯示控制列 */ + showControls?: boolean + /** 顯示迷你地圖 */ + showMiniMap?: boolean + /** 高度 (預設 100%) */ + height?: number | string +} + +// ============================================================================= +// 元件 +// ============================================================================= + +export function ServiceTopology({ + mode = 'compact', + showControls = true, + showMiniMap = false, + height = '100%', +}: ServiceTopologyProps) { + const { nodes, edges, hostCount, serviceCount, healthyCount, warningCount } = useTopologyData() + + // 空狀態 + if (nodes.length === 0) { + return ( +
+
🌐
+
等待主機資料...
+
Dashboard API 連線中
+
+ ) + } + + return ( +
+ {/* 狀態列 */} +
+ {hostCount} 主機 + {serviceCount} 服務 + {healthyCount} 健康 + {warningCount > 0 && {warningCount} 異常} +
+ + + + {showControls && } + {showMiniMap && ( + { + if (node.type === 'groupNode') return (node.data.color as string) || '#e0ddd4' + const status = node.data.status as string + if (status === 'up') return '#22C55E' + if (status === 'down') return '#cc2200' + return '#F59E0B' + }} + maskColor="rgba(245,244,237,0.8)" + style={{ border: '0.5px solid #e0ddd4', borderRadius: 6 }} + /> + )} + +
+ ) +} + +export default ServiceTopology diff --git a/apps/web/src/components/topology/edges/TopologyEdge.tsx b/apps/web/src/components/topology/edges/TopologyEdge.tsx new file mode 100644 index 00000000..69870961 --- /dev/null +++ b/apps/web/src/components/topology/edges/TopologyEdge.tsx @@ -0,0 +1,33 @@ +'use client' + +/** + * TopologyEdge — 自定義邊線 (漸層 + CSS 動畫) + * ============================================= + * Sprint 5 Phase 2 + * + * 建立時間: 2026-04-08 (台北時區) + */ + +import { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/react' + +export function TopologyEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data }: EdgeProps) { + const [edgePath] = getSmoothStepPath({ + sourceX, sourceY, targetX, targetY, + sourcePosition, targetPosition, + }) + + const isWarning = data?.status === 'warning' + + return ( + + ) +} diff --git a/apps/web/src/components/topology/hooks/useTopologyData.ts b/apps/web/src/components/topology/hooks/useTopologyData.ts new file mode 100644 index 00000000..da19109e --- /dev/null +++ b/apps/web/src/components/topology/hooks/useTopologyData.ts @@ -0,0 +1,156 @@ +/** + * useTopologyData — 從 dashboard store 讀取真實資料,轉換為 React Flow 節點/邊線 + * ================================================================================== + * Sprint 5 Phase 2: 串接真實 /api/v1/dashboard API + * + * 資料來源: + * - useDashboardStore → hosts[] (真實 TCP/HTTP 探測結果) + * - 每個 host 包含: ip, name, role, status, services[], metrics + * + * 零假數據: 所有節點資料來自 HostAggregator 真實探測 + * + * 建立時間: 2026-04-08 (台北時區) + * 建立者: Claude Code (Sprint 5 Phase 2) + */ + +import { useMemo } from 'react' +import { useDashboardStore, type Host, type HostService } from '@/stores/dashboard.store' +import type { Node, Edge } from '@xyflow/react' + +// ============================================================================= +// 群組配置 (對應 host_aggregator.py HostRole) +// ============================================================================= + +const GROUP_CONFIG: Record = { + devops: { label: '基礎設施', color: '#3B82F6', borderColor: 'rgba(59,130,246,0.25)', bgColor: 'rgba(59,130,246,0.02)' }, + security: { label: '安全中心', color: '#cc2200', borderColor: 'rgba(204,34,0,0.25)', bgColor: 'rgba(204,34,0,0.02)' }, + k3s: { label: 'K3s 叢集', color: '#A855F7', borderColor: 'rgba(168,85,247,0.25)', bgColor: 'rgba(168,85,247,0.02)' }, + ai_web: { label: 'AI/數據中心', color: '#F97316', borderColor: 'rgba(249,115,22,0.25)', bgColor: 'rgba(249,115,22,0.02)' }, +} + +// ============================================================================= +// 服務間依賴關係 (靜態定義,對應 ConfigMap 環境變數) +// 來源: config.py DATABASE_URL/REDIS_URL/OLLAMA_URL/OPENCLAW_URL + Sentry SDK +// ============================================================================= + +const SERVICE_DEPENDENCIES: [string, string][] = [ + // K3s → AI/數據 + ['awoooi-api', 'PostgreSQL'], + ['awoooi-api', 'Redis'], + ['awoooi-api', 'OpenClaw'], + ['awoooi-worker', 'PostgreSQL'], + ['awoooi-worker', 'Redis'], + ['awoooi-worker', 'Ollama'], + // AI 內部 + ['OpenClaw', 'Ollama'], + ['OpenClaw', 'Redis'], + // 監控 + ['SigNoz', 'ClickHouse'], + // DevOps + ['awoooi-api', 'Sentry'], + ['awoooi-web', 'Sentry'], +] + +// ============================================================================= +// Hook +// ============================================================================= + +export interface TopologyData { + nodes: Node[] + edges: Edge[] + hostCount: number + serviceCount: number + healthyCount: number + warningCount: number +} + +export function useTopologyData(): TopologyData { + const hosts = useDashboardStore(s => s.hosts) + + return useMemo(() => { + const nodes: Node[] = [] + const edges: Edge[] = [] + let serviceCount = 0 + let healthyCount = 0 + let warningCount = 0 + + // 群組節點 + 服務節點 + hosts.forEach((host, hostIdx) => { + const groupId = `group-${host.ip}` + const config = GROUP_CONFIG[host.role] || GROUP_CONFIG.devops + + // 群組節點 + nodes.push({ + id: groupId, + type: 'groupNode', + position: { x: (hostIdx % 2) * 350, y: Math.floor(hostIdx / 2) * 300 }, + data: { + label: `${config.label} (${host.ip.split('.').pop()})`, + role: host.role, + status: host.status, + ip: host.ip, + hostName: host.name, + serviceCount: host.services.length, + healthyServices: host.services.filter(s => s.status === 'up').length, + metrics: host.metrics, + color: config.color, + borderColor: config.borderColor, + bgColor: config.bgColor, + }, + }) + + // 服務節點 + host.services.forEach((svc, svcIdx) => { + const svcId = `svc-${host.ip}-${svc.name}` + serviceCount++ + + if (svc.status === 'up') healthyCount++ + else warningCount++ + + nodes.push({ + id: svcId, + type: 'serviceNode', + parentId: groupId, + position: { x: (svcIdx % 3) * 140 + 10, y: Math.floor(svcIdx / 3) * 50 + 60 }, + data: { + name: svc.name, + status: svc.status, + port: svc.port, + latency: svc.latency_ms, + error: svc.error, + hostIp: host.ip, + }, + }) + }) + }) + + // 依賴邊線 (只連接存在的服務) + const nodeIds = new Set(nodes.map(n => n.id)) + SERVICE_DEPENDENCIES.forEach(([fromName, toName]) => { + // 找到來源和目標節點 + const fromNode = nodes.find(n => n.type === 'serviceNode' && n.data.name === fromName) + const toNode = nodes.find(n => n.type === 'serviceNode' && n.data.name === toName) + + if (fromNode && toNode) { + edges.push({ + id: `edge-${fromNode.id}-${toNode.id}`, + source: fromNode.id, + target: toNode.id, + type: 'topologyEdge', + data: { + status: fromNode.data.status === 'up' && toNode.data.status === 'up' ? 'healthy' : 'warning', + }, + }) + } + }) + + return { + nodes, + edges, + hostCount: hosts.length, + serviceCount, + healthyCount, + warningCount, + } + }, [hosts]) +} diff --git a/apps/web/src/components/topology/index.ts b/apps/web/src/components/topology/index.ts new file mode 100644 index 00000000..12822761 --- /dev/null +++ b/apps/web/src/components/topology/index.ts @@ -0,0 +1,7 @@ +/** + * Topology 元件匯出 + * Sprint 5 Phase 2 + */ +export { ServiceTopology } from './ServiceTopology' +export { useTopologyData } from './hooks/useTopologyData' +export type { TopologyData } from './hooks/useTopologyData' diff --git a/apps/web/src/components/topology/nodes/GroupNode.tsx b/apps/web/src/components/topology/nodes/GroupNode.tsx new file mode 100644 index 00000000..67e02eba --- /dev/null +++ b/apps/web/src/components/topology/nodes/GroupNode.tsx @@ -0,0 +1,73 @@ +'use client' + +/** + * GroupNode — React Flow 自定義群組節點 + * ====================================== + * Sprint 5 Phase 2: 嵌套群組 (elkjs compound graph) + * + * 收合時: 群組名稱 + 服務數 + 健康摘要 + * 展開時: 背景容器,內部節點由 elkjs 排列 + * + * 建立時間: 2026-04-08 (台北時區) + */ + +import { memo, type CSSProperties } from 'react' +import { type NodeProps } from '@xyflow/react' + +function GroupNodeInner({ data }: NodeProps) { + const d = data as Record + const borderColor = d.borderColor || '#e0ddd4' + const bgColor = d.bgColor || 'rgba(0,0,0,0.01)' + const color = d.color || '#87867f' + const healthySvc = d.healthyServices || 0 + const totalSvc = d.serviceCount || 0 + const hasWarning = healthySvc < totalSvc + const status = d.status + + const style: CSSProperties = { + border: `0.5px solid ${borderColor}`, + borderRadius: 10, + background: bgColor, + padding: '8px 12px', + minWidth: 200, + minHeight: 80, + fontFamily: "'DM Mono', monospace", + } + + return ( +
+ {/* 群組標題 */} +
+ + {d.label} + +
+ + {/* 摘要 */} +
+ {totalSvc} 服務 · {hasWarning + ? ⚠ {totalSvc - healthySvc} 異常 + : ✓ 全部健康 + } +
+ + {/* 指標 (如果有) */} + {d.metrics && ( +
+ {d.metrics.cpu_percent != null && ( + CPU: 80 ? '#F59E0B' : '#141413' }}>{d.metrics.cpu_percent}% + )} + {d.metrics.memory_percent != null && ( + RAM: 80 ? '#F59E0B' : '#141413' }}>{d.metrics.memory_percent}% + )} +
+ )} +
+ ) +} + +export const GroupNode = memo(GroupNodeInner, (prev, next) => { + const p = prev.data as Record + const n = next.data as Record + return p.status === n.status && p.healthyServices === n.healthyServices && p.serviceCount === n.serviceCount +}) diff --git a/apps/web/src/components/topology/nodes/ServiceNode.tsx b/apps/web/src/components/topology/nodes/ServiceNode.tsx new file mode 100644 index 00000000..8a453030 --- /dev/null +++ b/apps/web/src/components/topology/nodes/ServiceNode.tsx @@ -0,0 +1,83 @@ +'use client' + +/** + * ServiceNode — React Flow 自定義服務節點 + * ======================================== + * Sprint 5 Phase 2: 顯示真實服務狀態 + * + * 設計規範: + * - 圓角 8px, 0.5px 邊框 + * - 狀態燈 + 服務名稱 + 端口 + * - DM Mono 字體 + * - React.memo + areEqual 防止無關 re-render + * + * 建立時間: 2026-04-08 (台北時區) + */ + +import { memo } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' + +// 狀態 → 色彩 +const STATUS_COLOR: Record = { + up: '#22C55E', + healthy: '#22C55E', + down: '#cc2200', + degraded: '#F59E0B', + warning: '#F59E0B', + thinking: '#4A90D9', + syncing: '#4A90D9', +} + +function ServiceNodeInner({ data }: NodeProps) { + const d = data as Record + const color = STATUS_COLOR[d.status] || '#b0ad9f' + const isDown = d.status === 'down' + const port = d.port ? `:${d.port}` : '' + + return ( +
+ + +
+ + + {d.name} + +
+ + {(port || d.latency) && ( +
+ {d.hostIp}{port} + {d.latency != null && ` · ${d.latency}ms`} +
+ )} + + {d.error && ( +
+ {String(d.error).slice(0, 30)} +
+ )} + + +
+ ) +} + +// memo: 只在 status/latency/error 變化時 re-render +export const ServiceNode = memo(ServiceNodeInner, (prev, next) => { + const p = prev.data as Record + const n = next.data as Record + return p.status === n.status && p.latency === n.latency && p.error === n.error +})