diff --git a/apps/web/src/components/topology/ServiceTopology.tsx b/apps/web/src/components/topology/ServiceTopology.tsx index 7e5faa83..be00be88 100644 --- a/apps/web/src/components/topology/ServiceTopology.tsx +++ b/apps/web/src/components/topology/ServiceTopology.tsx @@ -7,17 +7,18 @@ * * 功能: * - 從 useDashboardStore 讀取真實主機/服務資料 - * - React Flow 渲染嵌套群組 + 服務節點 + 依賴邊線 + * - elkjs 自動排版 (compound graph) + * - 展開/收合群組互動 * - 控制列: 全部展開/收合/只看異常 * - MiniMap + Controls * * 零假數據: 所有資料來自 HostAggregator 真實探測 * * 建立時間: 2026-04-08 (台北時區) - * 建立者: Claude Code (Sprint 5 Phase 2) + * 修改時間: 2026-04-10 — B2 elkjs + 展開收合 (Claude Code Sprint 5R) */ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ReactFlow, Controls, @@ -31,6 +32,7 @@ import '@xyflow/react/dist/style.css' import { useTranslations } from 'next-intl' import { useTopologyData } from './hooks/useTopologyData' +import { useElkLayout } from './hooks/useElkLayout' import { ServiceNode } from './nodes/ServiceNode' import { GroupNode } from './nodes/GroupNode' import { TopologyEdge } from './edges/TopologyEdge' @@ -74,10 +76,67 @@ export function ServiceTopology({ height = '100%', }: ServiceTopologyProps) { const t = useTranslations('dashboard') - const { nodes, edges, hostCount, serviceCount, healthyCount, warningCount } = useTopologyData() + const { nodes: rawNodes, edges, hostCount, serviceCount, healthyCount, warningCount } = useTopologyData() + + // 展開/收合狀態 + const [expandedGroups, setExpandedGroups] = useState>(new Set()) + // 只看異常 + const [warningOnly, setWarningOnly] = useState(false) + + // 切換單一群組展開 + const handleToggle = useCallback((groupId: string) => { + setExpandedGroups(prev => { + const next = new Set(prev) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + return next + }) + }, []) + + // 全部展開/收合 + const expandAll = useCallback(() => { + setExpandedGroups(new Set(rawNodes.filter(n => n.type === 'groupNode').map(n => n.id))) + }, [rawNodes]) + + const collapseAll = useCallback(() => { + setExpandedGroups(new Set()) + }, []) + + // 注入 onToggle + isExpanded + id 到 groupNode data + const augmentedNodes = useMemo(() => { + return rawNodes + .filter(node => { + if (!warningOnly) return true + // 只看異常: 保留有警告的群組 + 其底下的服務 + if (node.type === 'groupNode') { + const d = node.data as Record + return d.healthyServices < d.serviceCount + } + if (node.type === 'serviceNode') { + const d = node.data as Record + return d.status !== 'up' + } + return true + }) + .map(node => { + if (node.type !== 'groupNode') return node + return { + ...node, + data: { + ...node.data, + id: node.id, + isExpanded: expandedGroups.has(node.id), + onToggle: handleToggle, + }, + } + }) + }, [rawNodes, expandedGroups, handleToggle, warningOnly]) + + // elkjs 排版 + const { nodes: layoutNodes, loading } = useElkLayout(augmentedNodes, edges, expandedGroups) // 空狀態 - if (nodes.length === 0) { + if (rawNodes.length === 0) { return (
n.type === 'groupNode').length + const allCollapsed = expandedGroups.size === 0 + return (
{/* 狀態列 */} @@ -105,10 +167,54 @@ export function ServiceTopology({ {serviceCount} {healthyCount} {warningCount > 0 && {warningCount}} + {loading && 排版中…} +
+ + {/* 過濾控制列 */} +
+ + + {warningCount > 0 && ( + + )}
— 已展開的群組 ID + */ +export function useElkLayout( + rawNodes: Node[], + edges: Edge[], + expandedGroups: Set, +): ElkLayoutResult { + const [nodes, setNodes] = useState(rawNodes) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (rawNodes.length === 0) { + setNodes([]) + return + } + + setLoading(true) + + // 分類節點 + const groupNodes = rawNodes.filter(n => n.type === 'groupNode') + const serviceNodes = rawNodes.filter(n => n.type === 'serviceNode') + + // 建立 ELK Graph + const elkGraph = { + id: 'root', + layoutOptions: ELK_OPTIONS, + children: groupNodes.map(group => { + const isExpanded = expandedGroups.has(group.id) + const children = serviceNodes.filter(n => n.parentId === group.id) + + if (!isExpanded || children.length === 0) { + // 收合: 群組作為單一葉節點 + return { + id: group.id, + width: GROUP_COLLAPSED.width, + height: GROUP_COLLAPSED.height, + } + } + + // 展開: 群組作為 compound node,子節點自動排列 + const svcsPerRow = Math.ceil(Math.sqrt(children.length)) + const cols = Math.min(svcsPerRow, 3) + const rows = Math.ceil(children.length / cols) + const expandedWidth = cols * (SERVICE_NODE.width + GROUP_PADDING) + GROUP_PADDING * 2 + const expandedHeight = rows * (SERVICE_NODE.height + GROUP_PADDING) + GROUP_PADDING * 3 + 40 // 40 for header + + return { + id: group.id, + width: expandedWidth, + height: expandedHeight, + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.spacing.nodeNode': '10', + 'elk.padding': '[top=40,left=10,bottom=10,right=10]', + }, + children: children.map((svc, i) => ({ + id: svc.id, + width: SERVICE_NODE.width, + height: SERVICE_NODE.height, + // 預設位置,elk 會覆蓋 + x: (i % cols) * (SERVICE_NODE.width + GROUP_PADDING) + GROUP_PADDING, + y: Math.floor(i / cols) * (SERVICE_NODE.height + GROUP_PADDING) + 40, + })), + } + }), + // 只加入跨群組的邊線 (elk 排版用) + edges: edges + .filter(e => { + const srcNode = rawNodes.find(n => n.id === e.source) + const tgtNode = rawNodes.find(n => n.id === e.target) + return srcNode?.parentId !== tgtNode?.parentId + }) + .map(e => ({ + id: e.id, + sources: [e.source], + targets: [e.target], + })), + } + + elk.layout(elkGraph).then(result => { + if (!result.children) { + setLoading(false) + return + } + + // 將 elk 計算的位置映射回 React Flow 節點 + const positioned = new Map() + + result.children.forEach(elkNode => { + positioned.set(elkNode.id, { x: elkNode.x ?? 0, y: elkNode.y ?? 0 }) + // 子節點 (展開時) + if (elkNode.children) { + elkNode.children.forEach(child => { + positioned.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) + }) + } + }) + + const updatedNodes = rawNodes.map(node => { + const pos = positioned.get(node.id) + if (!pos) return node + return { ...node, position: pos } + }) + + setNodes(updatedNodes) + setLoading(false) + }).catch(() => { + // elk 失敗 → 保留原始位置 + setNodes(rawNodes) + setLoading(false) + }) + }, [rawNodes, edges, expandedGroups]) + + return { nodes, loading } +} diff --git a/apps/web/src/components/topology/nodes/GroupNode.tsx b/apps/web/src/components/topology/nodes/GroupNode.tsx index 03547c20..c354db72 100644 --- a/apps/web/src/components/topology/nodes/GroupNode.tsx +++ b/apps/web/src/components/topology/nodes/GroupNode.tsx @@ -3,17 +3,19 @@ /** * GroupNode — React Flow 自定義群組節點 * ====================================== - * Sprint 5 Phase 2: 嵌套群組 (elkjs compound graph) + * Sprint 5R B2: 新增展開/收合點擊互動 * - * 收合時: 群組名稱 + 服務數 + 健康摘要 - * 展開時: 背景容器,內部節點由 elkjs 排列 + * 收合時: 群組名稱 + 服務數 + 健康摘要 (點擊展開) + * 展開時: 背景容器,內部節點由 elkjs 排列 (點擊收合) * * 建立時間: 2026-04-08 (台北時區) + * 修改時間: 2026-04-10 — B2 展開/收合互動 (Claude Code Sprint 5R) */ -import { memo, type CSSProperties } from 'react' +import { memo, type CSSProperties, useCallback } from 'react' import { type NodeProps } from '@xyflow/react' import { useTranslations } from 'next-intl' +import { ChevronDown, ChevronRight } from 'lucide-react' // C2: 群組標籤 i18n const GROUP_LABELS: Record = { @@ -29,7 +31,12 @@ function GroupNodeInner({ data }: NodeProps) { const healthySvc = d.healthyServices || 0 const totalSvc = d.serviceCount || 0 const hasWarning = healthySvc < totalSvc - const status = d.status + const isExpanded = d.isExpanded || false + const onToggle = d.onToggle as ((id: string) => void) | undefined + + const handleClick = useCallback(() => { + onToggle?.(d.id || '') + }, [onToggle, d.id]) const style: CSSProperties = { border: `0.5px solid ${borderColor}`, @@ -37,14 +44,20 @@ function GroupNodeInner({ data }: NodeProps) { background: bgColor, padding: '8px 12px', minWidth: 200, - minHeight: 80, + minHeight: isExpanded ? 120 : 80, fontFamily: "'DM Mono', monospace", + cursor: 'pointer', + userSelect: 'none', } return ( -
+
{/* 群組標題 */}
+ {isExpanded + ? + : + } {t(GROUP_LABELS[d.labelKey] || 'groupInfra')} (.{d.ipSuffix}) @@ -76,5 +89,10 @@ function GroupNodeInner({ data }: NodeProps) { 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 + return ( + p.status === n.status && + p.healthyServices === n.healthyServices && + p.serviceCount === n.serviceCount && + p.isExpanded === n.isExpanded + ) })