Files
APIPark/frontend/packages/systemRunning/src/pages/SystemRunning.tsx
T
2024-12-19 16:17:50 +08:00

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>
)
}