feat(topology): B2 elkjs 自動排版 + 展開收合互動 + 過濾控制
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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:
@@ -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}
|
||||
|
||||
179
apps/web/src/components/topology/hooks/useElkLayout.ts
Normal file
179
apps/web/src/components/topology/hooks/useElkLayout.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user