feat(topology): B2 elkjs 自動排版 + 展開收合互動 + 過濾控制
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

- 新增 useElkLayout.ts: elkjs compound graph 自動計算節點位置
  - 收合時群組為葉節點, 展開時子服務納入 compound layout
  - 邊線參與跨群組排版
  - 異步計算, 失敗時 fallback 原位置
- GroupNode.tsx: 新增 onToggle/isExpanded props, ChevronDown/Right 圖示
- ServiceTopology.tsx: 整合 elkjs, 展開收合 state, 3 個控制按鈕
  - 全展開 / 全收合 / 只看異常
  - 排版中指示文字

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-10 11:29:16 +08:00
parent 49bfbd573c
commit 0b93f0e5c6
3 changed files with 317 additions and 14 deletions

View File

@@ -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<Set<string>>(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<string, any>
return d.healthyServices < d.serviceCount
}
if (node.type === 'serviceNode') {
const d = node.data as Record<string, any>
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 (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
@@ -91,6 +150,9 @@ export function ServiceTopology({
)
}
const allExpanded = expandedGroups.size === rawNodes.filter(n => n.type === 'groupNode').length
const allCollapsed = expandedGroups.size === 0
return (
<div style={{ height, position: 'relative' }}>
{/* 狀態列 */}
@@ -105,10 +167,54 @@ export function ServiceTopology({
<span>{serviceCount}</span>
<span style={{ color: '#22C55E' }}>{healthyCount}</span>
{warningCount > 0 && <span style={{ color: '#F59E0B' }}>{warningCount}</span>}
{loading && <span style={{ color: '#87867f', fontSize: 9 }}></span>}
</div>
{/* 過濾控制列 */}
<div style={{
position: 'absolute', top: 8, right: 12, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 10,
}}>
<button
onClick={expandAll}
disabled={allExpanded}
style={{
padding: '3px 8px', borderRadius: 4, border: '0.5px solid #e0ddd4',
background: 'rgba(250,249,243,0.9)', color: allExpanded ? '#c0beb8' : '#555550',
cursor: allExpanded ? 'default' : 'pointer', fontSize: 10,
}}
>
</button>
<button
onClick={collapseAll}
disabled={allCollapsed}
style={{
padding: '3px 8px', borderRadius: 4, border: '0.5px solid #e0ddd4',
background: 'rgba(250,249,243,0.9)', color: allCollapsed ? '#c0beb8' : '#555550',
cursor: allCollapsed ? 'default' : 'pointer', fontSize: 10,
}}
>
</button>
{warningCount > 0 && (
<button
onClick={() => setWarningOnly(w => !w)}
style={{
padding: '3px 8px', borderRadius: 4, border: `0.5px solid ${warningOnly ? '#F59E0B' : '#e0ddd4'}`,
background: warningOnly ? 'rgba(245,158,11,0.1)' : 'rgba(250,249,243,0.9)',
color: warningOnly ? '#F59E0B' : '#555550',
cursor: 'pointer', fontSize: 10,
}}
>
</button>
)}
</div>
<ReactFlow
nodes={nodes}
nodes={layoutNodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}

View File

@@ -0,0 +1,179 @@
/**
* useElkLayout — elkjs compound graph 自動排版
* ==============================================
* Sprint 5R B2: 取代硬編碼 position改用 elkjs 自動計算
*
* 支援:
* - Compound graph (GroupNode → ServiceNode 父子關係)
* - 依賴邊線參與排版
* - 展開/收合狀態
* - 節點尺寸估算
*
* 建立時間: 2026-04-10 (台北時區)
* 建立者: Claude Code (Sprint 5R B2)
*/
import { useState, useEffect } from 'react'
import ELK from 'elkjs/lib/elk.bundled.js'
import type { Node, Edge } from '@xyflow/react'
// =============================================================================
// Constants
// =============================================================================
const elk = new ELK()
/** 群組節點尺寸 (收合時) */
const GROUP_COLLAPSED = { width: 220, height: 90 }
/** 服務節點尺寸 */
const SERVICE_NODE = { width: 130, height: 44 }
/** 群組展開時 padding */
const GROUP_PADDING = 20
// =============================================================================
// ELK options
// =============================================================================
const ELK_OPTIONS = {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.layered.spacing.nodeNodeBetweenLayers': '60',
'elk.spacing.nodeNode': '30',
'elk.padding': '[top=50,left=20,bottom=20,right=20]',
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
}
// =============================================================================
// Types
// =============================================================================
export interface ElkLayoutResult {
nodes: Node[]
loading: boolean
}
// =============================================================================
// Hook
// =============================================================================
/**
* 將 React Flow 節點/邊線丟進 elkjs取得帶有正確 position 的節點陣列
*
* @param rawNodes 來自 useTopologyData 的原始節點
* @param edges 來自 useTopologyData 的邊線 (有助於 elk 排版)
* @param expandedGroups Set<string> — 已展開的群組 ID
*/
export function useElkLayout(
rawNodes: Node[],
edges: Edge[],
expandedGroups: Set<string>,
): ElkLayoutResult {
const [nodes, setNodes] = useState<Node[]>(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<string, { x: number; y: number }>()
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 }
}

View File

@@ -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<string, string> = {
@@ -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 (
<div style={style}>
<div style={style} onClick={handleClick}>
{/* 群組標題 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
{isExpanded
? <ChevronDown size={12} color={color} />
: <ChevronRight size={12} color={color} />
}
<span style={{ fontSize: 12, fontWeight: 600, color: '#141413' }}>
{t(GROUP_LABELS[d.labelKey] || 'groupInfra')} (.{d.ipSuffix})
</span>
@@ -76,5 +89,10 @@ function GroupNodeInner({ data }: NodeProps) {
export const GroupNode = memo(GroupNodeInner, (prev, next) => {
const p = prev.data as Record<string, any>
const n = next.data as Record<string, any>
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
)
})