mirror of
https://github.com/APIParkLab/APIPark.git
synced 2026-06-14 20:41:15 +08:00
631 lines
18 KiB
TypeScript
631 lines
18 KiB
TypeScript
import { LoadingOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons'
|
|
import G6, { Graph, Item, registerEdge } from '@antv/g6'
|
|
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const.tsx'
|
|
import { useBreadcrumb } from '@common/contexts/BreadcrumbContext.tsx'
|
|
import { useFetch } from '@common/hooks/http.ts'
|
|
import { $t } from '@common/locales/index.ts'
|
|
import { UnionFind, edgesFormatter, getNodeSpacing, nodesFormatter } from '@common/utils/systemRunning.ts'
|
|
import { RouterParams } from '@core/components/aoplatform/RenderRoutes.tsx'
|
|
import {
|
|
EDGE_STYLE,
|
|
END_ARROW_STYLE,
|
|
OUT_SPACE_CONTENT_EDGE_COLOR,
|
|
RELATIVE_PICTURE_NODE_FONTSIZE,
|
|
SELF_SPACE_CONTENT_EDGE_COLOR,
|
|
SYSTEM_TUNNING_CONFIG
|
|
} from '@core/const/system-running/const.ts'
|
|
import { GraphData, NodeClickItem, PictureTypeEnum } from '@core/const/system-running/type.ts'
|
|
import { App, Button, Spin, Tooltip } from 'antd'
|
|
import { debounce } from 'lodash-es'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useParams } from 'react-router-dom'
|
|
import SystemRunningInstruction from './SystemRunningInstruction.tsx'
|
|
|
|
export type TopologyItem = {
|
|
projects: TopologyProjectItem[]
|
|
services: TopologyServiceItem[]
|
|
}
|
|
|
|
export type TopologyProjectItem = {
|
|
id: string
|
|
name: string
|
|
invokeServices: string[]
|
|
clusters?: string
|
|
isApp?: boolean
|
|
isServer?: boolean
|
|
}
|
|
|
|
export type TopologyServiceItem = {
|
|
id: string
|
|
name: string
|
|
project: string
|
|
}
|
|
|
|
enum EdgeEvent {
|
|
Mouseenter = 'mouseenter',
|
|
Mouseleave = 'mouseleave'
|
|
}
|
|
|
|
type nodeAny = unknown
|
|
const subjectColors = [
|
|
'#5F95FF', // blue
|
|
'#61DDAA',
|
|
'#65789B',
|
|
'#F6BD16',
|
|
'#7262FD',
|
|
'#78D3F8',
|
|
'#9661BC',
|
|
'#F6903D',
|
|
'#008685',
|
|
'#F08BB4'
|
|
]
|
|
const backColor = '#fff'
|
|
const theme = 'default'
|
|
const disableColor = '#777'
|
|
const colorSets = G6.Util.getColorSetsBySubjectColors(subjectColors, backColor, theme, disableColor)
|
|
|
|
// cache the initial node and combo info
|
|
const itemMap: Record<string, unknown> = {}
|
|
|
|
export default function SystemRunning() {
|
|
const { message } = App.useApp()
|
|
const graphRef = useRef<Graph>(null)
|
|
const graphContainerRef = useRef<HTMLDivElement>(null)
|
|
const { topologyId } = useParams<RouterParams>()
|
|
const [graph, setGraph] = useState<Graph | null>(null)
|
|
const [graphData, setGraphData] = useState<GraphData>()
|
|
const [currentNode, setCurrentNode] = useState<string>()
|
|
const [showEdgeTooltip, setShowEdgeTooltip] = useState<boolean>(false)
|
|
const [edgeTooltipX, setEdgeTooltipX] = useState(0)
|
|
const [edgeTooltipY, setEdgeTooltipY] = useState(0)
|
|
const [edgeTooltipContent, setEdgeTooltipContent] = useState<TopologyProjectItem>()
|
|
const [pictureType, setPictureType] = useState<PictureTypeEnum>(PictureTypeEnum.Global)
|
|
const { fetchData } = useFetch()
|
|
const textColor: string = '#666'
|
|
const [showGraph, setShowGraph] = useState<boolean>(false)
|
|
const { setBreadcrumb } = useBreadcrumb()
|
|
const [zoomNum, setZoomNum] = useState<number>(1)
|
|
const [loading, setLoading] = useState<boolean>(true)
|
|
const [categories, setCategories] = useState<unknown>(undefined)
|
|
|
|
/**
|
|
* @description 关联关系转化器,将接口数据转为 g6 渲染需要的格式
|
|
*/
|
|
const relativeFormatter = (data: TopologyItem) => {
|
|
const { projects, services } = data
|
|
const serviceMap: Map<string, TopologyServiceItem> = new Map()
|
|
services.forEach((s: TopologyServiceItem) => {
|
|
serviceMap.set(s.id, s)
|
|
})
|
|
// Map<projectId, Map<invokeProject, invokeService[]>>
|
|
const tmpProjectConnectMap: Map<string, Map<string, TopologyServiceItem[]>> = new Map()
|
|
projects.forEach((p: TopologyProjectItem) => {
|
|
const invokedMap = new Map<string, TopologyServiceItem[]>()
|
|
p.invokeServices?.forEach((s: string) => {
|
|
const invokedProject = serviceMap.get(s)
|
|
if (invokedProject) {
|
|
invokedMap.has(invokedProject.project)
|
|
? invokedMap.get(invokedProject.project)?.push(invokedProject)
|
|
: invokedMap.set(invokedProject.project, [invokedProject])
|
|
} else {
|
|
console.warn('存在无所属系统的服务:', s)
|
|
}
|
|
})
|
|
tmpProjectConnectMap.set(p.id, invokedMap)
|
|
})
|
|
const newNodes = nodesFormatter(projects)
|
|
const newEdges = edgesFormatter(tmpProjectConnectMap)
|
|
|
|
// 从 edges 中提取所有唯一的节点,并将 Set 转换为数组
|
|
const allNodeIds: string[] = Array.from(new Set(newEdges.flatMap((edge) => [edge.source, edge.target]))) as string[]
|
|
|
|
// 初始化 UnionFind,并处理所有的边
|
|
const unionFind = new UnionFind(allNodeIds)
|
|
newEdges.forEach(({ source, target }) => {
|
|
unionFind.union(source, target)
|
|
})
|
|
|
|
// 预设的颜色数组
|
|
const colors: string[] = [
|
|
'#FF0000',
|
|
'#00FF00',
|
|
'#0000FF',
|
|
'#FFFF00',
|
|
'#FF00FF'
|
|
// ... 根据需要添加更多颜色
|
|
]
|
|
|
|
// 使用 Union-Find 算法处理所有的边
|
|
tmpProjectConnectMap.forEach(({ source, target }) => {
|
|
unionFind.union(source, target)
|
|
})
|
|
|
|
// 为每个连通分量分配颜色,并更新 nodes 数组
|
|
const clusterToColor: Record<string, string> = {}
|
|
const categories: Record<string, string[]> = {}
|
|
const newCom = []
|
|
|
|
newNodes.forEach((node) => {
|
|
const root = unionFind.find(node.id)
|
|
categories[root] = categories[root] || []
|
|
categories[root].push(node.id)
|
|
if (!clusterToColor[root]) {
|
|
// 分配颜色,确保同一连通分量的节点颜色相同
|
|
clusterToColor[root] = colors[Math.max(0, Object.keys(clusterToColor).length) % colors.length]
|
|
}
|
|
node.cluster = root || 'none'
|
|
node.comboId = `${root || 'none'}-combo`
|
|
// node.color = clusterToColor[root];
|
|
})
|
|
|
|
let i: number = 0
|
|
|
|
for (const c in categories) {
|
|
const color = colorSets[i % colorSets.length]
|
|
const comboStyle = {
|
|
stroke: color.mainStroke,
|
|
fill: color.mainFill,
|
|
opacity: 0.8
|
|
}
|
|
const comboId = `${c === 'undefined' ? 'none' : c || 'none'}-combo`
|
|
newCom.push(
|
|
comboId === 'none-combo'
|
|
? {
|
|
id: comboId,
|
|
style: {
|
|
fill: 'transparent',
|
|
stroke: 'transparent',
|
|
fillOpacity: 0,
|
|
strokeOpacity: 0,
|
|
active: {
|
|
// 设置激活状态下的透明度
|
|
fill: 'transparent',
|
|
stroke: 'transparent',
|
|
fillOpacity: 0,
|
|
strokeOpacity: 0
|
|
},
|
|
inactive: {
|
|
fill: 'transparent',
|
|
stroke: 'transparent',
|
|
// 设置非激活状态下的透明度
|
|
fillOpacity: 0,
|
|
strokeOpacity: 0
|
|
},
|
|
highlight: {
|
|
fill: 'transparent',
|
|
stroke: 'transparent',
|
|
// 设置高亮状态下的透明度
|
|
fillOpacity: 0,
|
|
strokeOpacity: 0
|
|
}
|
|
}
|
|
}
|
|
: {
|
|
id: comboId,
|
|
style: comboStyle
|
|
}
|
|
)
|
|
itemMap[comboId] = { style: { ...comboStyle } }
|
|
i++
|
|
}
|
|
|
|
newNodes.forEach((node) => {
|
|
const parentCombo = itemMap[node.comboId]
|
|
if (node.isApp) {
|
|
node.style = {
|
|
stroke: '#ffa940',
|
|
fill: '#ffa94033'
|
|
}
|
|
} else if (parentCombo) {
|
|
node.style = {
|
|
stroke: parentCombo.style.stroke,
|
|
fill: parentCombo.style.fill
|
|
}
|
|
}
|
|
// node.color = clusterToColor[root];
|
|
})
|
|
|
|
return {
|
|
nodes: newNodes,
|
|
edges: newEdges,
|
|
combos: newCom
|
|
}
|
|
}
|
|
|
|
const getNodeData = () => {
|
|
setLoading(true)
|
|
fetchData<BasicResponse<TopologyItem>>('topology', {
|
|
method: 'GET',
|
|
eoTransformKeys: ['invoke_services', 'is_app', 'is_server']
|
|
})
|
|
.then((response) => {
|
|
const { code, data, msg } = response
|
|
if (code === STATUS_CODE.SUCCESS) {
|
|
const newGraphData = relativeFormatter(data)
|
|
setGraphData(newGraphData)
|
|
setShowGraph(newGraphData?.nodes?.length > 0)
|
|
} else {
|
|
message.error(msg || $t(RESPONSE_TIPS.error))
|
|
}
|
|
})
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
const handleWindowResize = useCallback(
|
|
debounce(() => {
|
|
if (graphContainerRef.current && graphRef.current && !graphRef.current?.get('destroyed')) {
|
|
graphRef.current.changeSize(graphContainerRef.current.offsetWidth, graphContainerRef.current.offsetHeight)
|
|
graphRef.current?.fitCenter()
|
|
// graphRef.current?.fitView()
|
|
}
|
|
}, 400),
|
|
[]
|
|
)
|
|
|
|
/**
|
|
* @description 点击节点的回调
|
|
*/
|
|
const clickNode = (item: NodeClickItem) => {
|
|
// console.log(item)
|
|
// router.navigate(['/', 'home', 'api-relative', item.id])
|
|
}
|
|
|
|
const updateSelected = () => {
|
|
if (!currentNode) return
|
|
// 设置节点状态
|
|
graph?.setItemState(currentNode, 'selected', true)
|
|
}
|
|
|
|
/**
|
|
* @description 更新缩放比例
|
|
*/
|
|
const updateZoomTo = (increase: boolean) => {
|
|
const zoom: number = graph?.getZoom() || zoomNum
|
|
if ((increase && zoom * 10 >= 20) || (!increase && zoom * 10 <= 2)) return
|
|
setZoomNum(increase ? (zoom * 10 + 2) / 10 : (zoom * 10 - 2) / 10)
|
|
graph?.zoomTo(increase ? (zoom * 10 + 2) / 10 : (zoom * 10 - 2) / 10)
|
|
}
|
|
|
|
const initGraph = () => {
|
|
return new G6.Graph({
|
|
container: graphContainerRef.current as HTMLDivElement,
|
|
groupByTypes: false,
|
|
// plugins: [tooltip],
|
|
fitCenter: true,
|
|
// fitView:true,
|
|
layout: {
|
|
type: 'comboForce',
|
|
// 稳定系数,初始动画的加载时长(稳定性)=节点数量/稳定系数
|
|
alphaDecay: 0.08,
|
|
// // 因为有分组的存在,整体布局需要往左偏移一点
|
|
// // center: [(graphContainerRef.current?.scrollWidth || 300) / 2 - 150,( graphContainerRef.current?.scrollHeight || 0) / 2],
|
|
preventOverlap: true,
|
|
preventNodeOverlap: true,
|
|
preventComboOverlap: true,
|
|
// nodeCollideStrength:1,
|
|
// collideStrength:1,
|
|
comboCollideStrength: 0.9,
|
|
nodeSize: 24,
|
|
padding: [20, 20, 20, 20],
|
|
// linkDistance: 30,
|
|
nodeStrength: -10,
|
|
edgeStrength: 0.1,
|
|
// nodeSpacing:40,
|
|
// comboSpacing:10,
|
|
comboPadding: 30,
|
|
clustering: true,
|
|
clusterNodeStrength: 1000,
|
|
clusterEdgeDistance: 50,
|
|
clusterNodeSize: 100
|
|
// clusterFociStrength: 1,
|
|
// charge: (d: nodeAny) => {
|
|
// return 100
|
|
// },
|
|
// linkStrength:()=>{
|
|
// return 100
|
|
// },
|
|
// nodeSpacing: (d: nodeAny,v:nodeAny) => {
|
|
// console.log(d, v)
|
|
// if (d.comboId=== 'none-combo' || d.cluster==="none") {
|
|
// return 40
|
|
// }
|
|
// return 50
|
|
// },
|
|
// onTick:()=>{console.log('ticking')},
|
|
// onLayoutEnd:()=>{console.log('layout end')}
|
|
},
|
|
modes: {
|
|
default: ['drag-combo', 'drag-canvas', 'drag-node', 'zoom-canvas', 'activate-relations']
|
|
},
|
|
defaultNode: {
|
|
size: [24, 24],
|
|
style: {
|
|
radius: 5,
|
|
stroke: '#69c0ff',
|
|
lineWidth: 1,
|
|
fillOpacity: 1
|
|
},
|
|
labelCfg: {
|
|
style: {
|
|
fontSize: RELATIVE_PICTURE_NODE_FONTSIZE,
|
|
fill: textColor
|
|
},
|
|
position: 'bottom',
|
|
offset: 12
|
|
}
|
|
},
|
|
defaultEdge: {
|
|
// type: 'quadratic',
|
|
label: $t('调用服务'),
|
|
labelCfg: {
|
|
style: {
|
|
fill: '5B8FF9',
|
|
opacity: 0
|
|
}
|
|
}
|
|
},
|
|
defaultCombo: {
|
|
labelCfg: {
|
|
style: {
|
|
fill: '#666'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const updateEdgeLabel = (type: EdgeEvent, edge: Item) => {
|
|
if (type === EdgeEvent.Mouseenter) {
|
|
// hover 边的时候出提示
|
|
edge.update({
|
|
labelCfg: {
|
|
style: {
|
|
opacity: 1,
|
|
fill: '#5B8FF9',
|
|
// @ts-expect-error g6 内部没定义好类型
|
|
cursor: 'pointer'
|
|
}
|
|
}
|
|
})
|
|
return
|
|
}
|
|
// 移出边时需要隐藏提示
|
|
edge.update({
|
|
labelCfg: {
|
|
style: {
|
|
opacity: 0,
|
|
// @ts-ignore g6 内部没定义好类型
|
|
cursor: 'pointer'
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const refreshDragNodePosition = (e: unknown) => {
|
|
const model = e.item.get('model')
|
|
model.fx = e.x
|
|
model.fy = e.y
|
|
}
|
|
|
|
const initGraphEvent = (
|
|
graph: Graph,
|
|
opts: {
|
|
onClickEdge?: (model: { target: string; source: string }) => void
|
|
onClickNode?: (item: NodeClickItem) => void
|
|
}
|
|
) => {
|
|
graph.on('node:mouseenter', (e) => {
|
|
const node = e.item
|
|
if (!node) return
|
|
// hover 出文本
|
|
const element = node.getKeyShape()
|
|
element.attr('cursor', 'pointer')
|
|
})
|
|
|
|
graph.on('node:mouseleave', (e) => {
|
|
const node = e.item
|
|
if (!node) return
|
|
const element = node.getKeyShape()
|
|
element.attr('cursor', 'default')
|
|
})
|
|
|
|
// 目前只找到这种性能较低的方法
|
|
graph.edge((edge) => {
|
|
const sourceNode = graph.findById(edge.source as string)
|
|
const theme = sourceNode?._cfg?.model?.isSelfSpace ? SELF_SPACE_CONTENT_EDGE_COLOR : OUT_SPACE_CONTENT_EDGE_COLOR
|
|
return {
|
|
id: edge.id,
|
|
...EDGE_STYLE,
|
|
style: {
|
|
stroke: theme,
|
|
endArrow: {
|
|
...END_ARROW_STYLE,
|
|
fill: theme
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
graph.on('edge:mouseenter', (evt) => {
|
|
if (evt.item) {
|
|
graph.setItemState(evt.item, 'running', true)
|
|
}
|
|
const edge = evt.item
|
|
if (edge) {
|
|
updateEdgeLabel(EdgeEvent.Mouseenter, edge)
|
|
const model = edge.getModel()
|
|
const { endPoint, startPoint } = model
|
|
// y=endPoint.y - height / 2,在同一水平线上,x值=endPoint.x - width - 10
|
|
const y = (endPoint.y + startPoint.y) / 2
|
|
const x = (endPoint.x + startPoint.x) / 2
|
|
const point = graph.getCanvasByPoint(x, y)
|
|
setEdgeTooltipX(point.x + 194) // 加上页面左侧导航菜单宽度
|
|
setEdgeTooltipY(point.y + 50) // 加上页面顶部导航与按钮高度
|
|
setShowEdgeTooltip(true)
|
|
setEdgeTooltipContent(model?._projectInfo)
|
|
}
|
|
})
|
|
|
|
graph.on('edge:mouseleave', (evt) => {
|
|
const { item } = evt
|
|
if (item) {
|
|
graph.clearItemStates(item, ['running'])
|
|
updateEdgeLabel(EdgeEvent.Mouseleave, item)
|
|
}
|
|
setEdgeTooltipContent(undefined)
|
|
setShowEdgeTooltip(false)
|
|
})
|
|
}
|
|
|
|
const getGraph = (opts: {
|
|
onClickEdge?: (model: { target: string; source: string }) => void
|
|
onClickNode?: (item: NodeClickItem) => void
|
|
}) => {
|
|
const graph = initGraph()
|
|
graph.setMaxZoom(3)
|
|
graph.setMinZoom(0.2)
|
|
initGraphEvent(graph, opts)
|
|
return graph
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (topologyId !== undefined) {
|
|
setPictureType(PictureTypeEnum.Part)
|
|
setCurrentNode(topologyId)
|
|
return
|
|
}
|
|
setPictureType(PictureTypeEnum.Global)
|
|
}, [topologyId])
|
|
|
|
useEffect(() => {
|
|
if (graphContainerRef.current) {
|
|
registerEdge('line-running', SYSTEM_TUNNING_CONFIG, 'quadratic')
|
|
const graph = getGraph({
|
|
onClickNode: (item: NodeClickItem) => {
|
|
clickNode(item)
|
|
}
|
|
})
|
|
|
|
graph.on('beforelayout', async () => {
|
|
updateSelected()
|
|
})
|
|
|
|
setGraph(graph)
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
;(graphRef as any).current = graph
|
|
|
|
// 添加窗口大小变化的监听器
|
|
window.addEventListener('resize', handleWindowResize)
|
|
|
|
// 组件卸载时清理资源
|
|
return () => {
|
|
window.removeEventListener('resize', handleWindowResize)
|
|
if (graphRef.current) {
|
|
graphRef.current.destroy()
|
|
}
|
|
}
|
|
}
|
|
}, [handleWindowResize, showGraph, loading])
|
|
|
|
useEffect(() => {
|
|
if (!graph || !graphData || !showGraph || loading) return
|
|
graph.clear()
|
|
graph.data(graphData)
|
|
|
|
setTimeout(() => {
|
|
const { nodes } = graphData as GraphData
|
|
|
|
if (nodes?.length) {
|
|
graph.updateLayout({
|
|
nodeSpacing: getNodeSpacing(nodes.length, nodes),
|
|
comboSpacing: getNodeSpacing(nodes.length)
|
|
})
|
|
}
|
|
|
|
graph.render()
|
|
}, 200)
|
|
}, [graph, graphData, showGraph, loading])
|
|
|
|
useEffect(() => {
|
|
setBreadcrumb([{ title: $t('系统拓扑图') }])
|
|
getNodeData()
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
{showGraph ? (
|
|
<div className="overflow-hidden relative w-full h-full pb-PAGE_INSIDE_B pr-PAGE_INSIDE_X">
|
|
<div className=" absolute top-[10px] right-PAGE_INSIDE_X">
|
|
<div className="flex justify-between">
|
|
<div></div>
|
|
<div className="border border-solid border-color-base rounded z-[999] h-8 bg-[#fff]">
|
|
<Button
|
|
id="zoom-in-button"
|
|
type="text"
|
|
title={$t('放大')}
|
|
icon={<ZoomInOutlined />}
|
|
onClick={() => updateZoomTo(true)}
|
|
/>
|
|
<Button
|
|
id="zoom-out-button"
|
|
type="text"
|
|
title={$t('缩小')}
|
|
icon={<ZoomOutOutlined />}
|
|
onClick={() => updateZoomTo(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="w-full h-full" ref={graphContainerRef}></div>
|
|
</div>
|
|
) : (
|
|
<Spin
|
|
wrapperClassName="h-calc-100vh-minus-navbar"
|
|
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
|
|
spinning={loading}
|
|
>
|
|
{!loading && <SystemRunningInstruction />}
|
|
</Spin>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const EdgeToolTips = ({ x, y, content }) => {
|
|
return (
|
|
// <div
|
|
// className="absolute"
|
|
// style={{
|
|
// left: x,
|
|
// top: y
|
|
// }}
|
|
// >
|
|
// <div className="edge-tooltip-arrow"></div>
|
|
// <div className="edge-tooltip-inner">
|
|
// <div className="edge-tooltip-title">Edge</div>
|
|
// </div>
|
|
// </div>
|
|
<Tooltip
|
|
open={true}
|
|
title={
|
|
<div className="max-w-[200px] break-words flex flex-col">
|
|
{content.map((x: TopologyServiceItem) => (
|
|
<span className="text-MAIN_TEXT">{x?.name || ''}</span>
|
|
))}
|
|
</div>
|
|
}
|
|
placement="bottomLeft"
|
|
color="#fff"
|
|
key="edge-tooltip"
|
|
>
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: x,
|
|
top: y
|
|
}}
|
|
></div>
|
|
</Tooltip>
|
|
)
|
|
}
|