From 04f64672d4d4e87a6d9eca206024ab1c1a3836fc Mon Sep 17 00:00:00 2001
From: elliotxx <951376975@qq.com>
Date: Fri, 27 Dec 2024 12:03:32 +0800
Subject: [PATCH] refactor(topology): beautify topology graph (#681)

## What type of PR is this?

/kind feature

## What this PR does / why we need it:

Enhances the topology map component with the following improvements:
1. Improved node rendering for better visual clarity and performance.
2. Added edge animations to enhance the user experience and make the
topology map more dynamic.


![topology-map-demo](https://github.com/user-attachments/assets/400d9c57-19c0-476a-80f1-bc472c19c99c)

## Which issue(s) this PR fixes:

Fixes #

---------

Co-authored-by: hai-tian <wb-th358723@antgroup.com>
---
 ui/src/pages/insightDetail/cluster/index.tsx  |  16 +-
 .../components/sourceTable/index.tsx          |   2 +-
 .../components/topologyMap/index.tsx          | 903 ++++++++++--------
 .../components/topologyMap/nodeLabel.tsx      |  43 -
 .../components/topologyMap/style.module.less  |  50 +-
 ui/src/pages/insightDetail/group/index.tsx    |  30 +-
 .../pages/insightDetail/namespace/index.tsx   |  18 +-
 ui/src/pages/insightDetail/resource/index.tsx |  25 +-
 ui/src/utils/tools.ts                         |  20 +-
 9 files changed, 654 insertions(+), 453 deletions(-)
 delete mode 100644 ui/src/pages/insightDetail/components/topologyMap/nodeLabel.tsx

diff --git a/ui/src/pages/insightDetail/cluster/index.tsx b/ui/src/pages/insightDetail/cluster/index.tsx
index 947259c5..5fe2797f 100644
--- a/ui/src/pages/insightDetail/cluster/index.tsx
+++ b/ui/src/pages/insightDetail/cluster/index.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import { NavLink, useLocation } from 'react-router-dom'
 import queryString from 'query-string'
 import { Breadcrumb, Tooltip } from 'antd'
@@ -42,6 +42,8 @@ const ClusterDetail = () => {
   const [selectedCluster, setSelectedCluster] = useState<any>()
   const [clusterOptions, setClusterOptions] = useState<string[]>([])
 
+  const drawRef = useRef(null)
+
   useEffect(() => {
     if (selectedCluster) {
       const result = generateTopologyData(multiTopologyData?.[selectedCluster])
@@ -195,6 +197,16 @@ const ClusterDetail = () => {
     }
   }, [multiTopologyData])
 
+  useEffect(() => {
+    if (selectedCluster && currentTab === 'Topology') {
+      const topologyData =
+        multiTopologyData &&
+        selectedCluster &&
+        generateTopologyData(multiTopologyData?.[selectedCluster])
+      drawRef.current?.drawGraph(topologyData)
+    }
+  }, [multiTopologyData, selectedCluster, currentTab])
+
   useEffect(() => {
     getClusterDetail()
     getAudit(false)
@@ -292,11 +304,11 @@ const ClusterDetail = () => {
         return (
           <>
             <TopologyMap
+              ref={drawRef}
               tableName={tableName}
               selectedCluster={selectedCluster}
               handleChangeCluster={handleChangeCluster}
               clusterOptions={clusterOptions}
-              topologyData={topologyData}
               topologyLoading={topologyLoading}
               onTopologyNodeClick={onTopologyNodeClick}
             />
diff --git a/ui/src/pages/insightDetail/components/sourceTable/index.tsx b/ui/src/pages/insightDetail/components/sourceTable/index.tsx
index 7bdd65ca..70e5f4ad 100644
--- a/ui/src/pages/insightDetail/components/sourceTable/index.tsx
+++ b/ui/src/pages/insightDetail/components/sourceTable/index.tsx
@@ -207,7 +207,7 @@ const SourceTable = ({ queryStr, tableName }: IProps) => {
         columns={columns}
         dataSource={tableData}
         rowKey={record => {
-          return `${record?.object?.metadata?.name}_${record?.object?.metadata?.namespace}_${record?.object?.apiVersion}_${record?.object?.kind}`
+          return `${record?.cluster}_${record?.object?.metadata?.name}_${record?.object?.metadata?.namespace}_${record?.object?.apiVersion}_${record?.object?.kind}`
         }}
         onChange={handleTableChange}
         pagination={{
diff --git a/ui/src/pages/insightDetail/components/topologyMap/index.tsx b/ui/src/pages/insightDetail/components/topologyMap/index.tsx
index d801d194..1f5a15c3 100644
--- a/ui/src/pages/insightDetail/components/topologyMap/index.tsx
+++ b/ui/src/pages/insightDetail/components/topologyMap/index.tsx
@@ -1,88 +1,146 @@
-import React, { memo, useLayoutEffect, useRef, useState } from 'react'
+import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'
 import { Select } from 'antd'
 import G6 from '@antv/g6'
-import type { IAbstractGraph, IG6GraphEvent } from '@antv/g6'
-import type { Point } from '@antv/g-base/lib/types'
+import type {
+  IG6GraphEvent,
+  IGroup,
+  ModelConfig,
+  IAbstractGraph,
+} from '@antv/g6'
 import { useLocation, useNavigate } from 'react-router-dom'
 import queryString from 'query-string'
-import {
-  Rect,
-  Group,
-  createNodeFromReact,
-  appenAutoShapeListener,
-  Image,
-} from '@antv/g6-react-node'
 import { useTranslation } from 'react-i18next'
 import Loading from '@/components/loading'
-import transferPng from '@/assets/transfer.png'
-import NodeLabel from './nodeLabel'
+import transferImg from '@/assets/transfer.png'
+import { ICON_MAP } from '@/utils/images'
 
 import styles from './style.module.less'
+interface NodeConfig extends ModelConfig {
+  data?: {
+    name?: string
+    count?: number
+    resourceGroup?: {
+      name: string
+      [key: string]: any
+    }
+  }
+  label?: string
+  id?: string
+  resourceGroup?: {
+    name: string
+    [key: string]: any
+  }
+}
+
+interface NodeModel {
+  id: string
+  name?: string
+  label?: string
+  resourceGroup?: {
+    name: string
+  }
+  data?: {
+    count?: number
+    resourceGroup?: {
+      name: string
+    }
+  }
+}
 
-function getTextSize(str: string, maxWidth: number, fontSize: number) {
-  const width = G6.Util.getTextSize(str, fontSize)?.[0]
-  return width > maxWidth ? maxWidth : width
+function getTextWidth(str: string, fontSize: number) {
+  const canvas = document.createElement('canvas')
+  const context = canvas.getContext('2d')!
+  context.font = `${fontSize}px sans-serif`
+  return context.measureText(str).width
 }
 
-function fittingString(str: any, maxWidth: number, fontSize: number) {
+function fittingString(str: string, maxWidth: number, fontSize: number) {
   const ellipsis = '...'
-  const ellipsisLength = G6.Util.getTextSize(ellipsis, fontSize)?.[0]
-  let currentWidth = 0
-  let res = str
-  const pattern = new RegExp('[\u4E00-\u9FA5]+') // distinguish the Chinese charactors and letters
-  str?.split('')?.forEach((letter, i) => {
-    if (currentWidth > maxWidth - ellipsisLength) return
-    if (pattern?.test(letter)) {
-      // Chinese charactors
-      currentWidth += fontSize
-    } else {
-      // get the width of single letter according to the fontSize
-      currentWidth += G6.Util.getLetterWidth(letter, fontSize)
-    }
-    if (currentWidth > maxWidth - ellipsisLength) {
-      res = `${str?.substr(0, i)}${ellipsis}`
+  const ellipsisLength = getTextWidth(ellipsis, fontSize)
+
+  if (maxWidth <= 0) {
+    return ''
+  }
+
+  const width = getTextWidth(str, fontSize)
+  if (width <= maxWidth) {
+    return str
+  }
+
+  let len = str.length
+  while (len > 0) {
+    const substr = str.substring(0, len)
+    const subWidth = getTextWidth(substr, fontSize)
+
+    if (subWidth + ellipsisLength <= maxWidth) {
+      return substr + ellipsis
     }
-  })
-  return res
+
+    len--
+  }
+
+  return str
 }
 
-type propsType = {
-  value?: Record<string, any>[]
-  open?: boolean
-  hiddenButtonInfo?: any
-  itemWidth?: number
+function getNodeName(cfg: NodeConfig, type: string) {
+  if (type === 'resource') {
+    const [left, right] = cfg?.id?.split(':') || []
+    const leftList = left?.split('.')
+    const leftListLength = leftList?.length || 0
+    const leftLast = leftList?.[leftListLength - 1]
+    return `${leftLast}:${right}`
+  }
+  const list = cfg?.label?.split('.')
+  const len = list?.length || 0
+  return list?.[len - 1] || ''
+}
+
+interface OverviewTooltipProps {
   type: string
+  itemWidth: number
+  hiddenButtonInfo: {
+    x: number
+    y: number
+    e?: IG6GraphEvent
+  }
+  open: boolean
 }
 
-// eslint-disable-next-line react/display-name
-const OverviewTooltip = memo((props: propsType) => {
-  const model = props?.hiddenButtonInfo?.e.item?.get('model')
+const OverviewTooltip: React.FC<OverviewTooltipProps> = ({
+  type,
+  hiddenButtonInfo,
+}) => {
+  const model = hiddenButtonInfo?.e?.item?.get('model') as NodeModel
   const boxStyle: any = {
     background: '#fff',
     border: '1px solid #f5f5f5',
     position: 'absolute',
-    top: props?.hiddenButtonInfo?.y - 60 || -500,
-    left: props?.hiddenButtonInfo?.x || -500,
+    top: hiddenButtonInfo?.y || -500,
+    left: hiddenButtonInfo?.x + 14 || -500,
+    transform: 'translate(-50%, -100%)',
     zIndex: 5,
-    padding: 10,
+    padding: '6px 12px',
     borderRadius: 8,
-    fontSize: 12,
+    boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
   }
+
   const itemStyle = {
-    color: '#646566',
-    margin: '10px 5px',
+    color: '#333',
+    fontSize: 14,
+    whiteSpace: 'nowrap',
   }
+
   return (
     <div style={boxStyle}>
       <div style={itemStyle}>
-        {props?.type === 'cluster' ? model?.label : model?.id}
+        {type === 'cluster' ? model?.label : model?.id}
       </div>
     </div>
   )
-})
+}
 
 type IProps = {
-  topologyData: any
+  topologyData?: any
   topologyLoading?: boolean
   onTopologyNodeClick?: (node: any) => void
   isResource?: boolean
@@ -92,20 +150,19 @@ type IProps = {
   clusterOptions?: string[]
 }
 
-const TopologyMap = ({
-  onTopologyNodeClick,
-  topologyData,
-  topologyLoading,
-  isResource,
-  tableName,
-  selectedCluster,
-  clusterOptions,
-  handleChangeCluster,
-}: IProps) => {
+const TopologyMap = forwardRef((props: IProps, drawRef) => {
+  const {
+    onTopologyNodeClick,
+    topologyLoading,
+    isResource,
+    tableName,
+    selectedCluster,
+    clusterOptions,
+    handleChangeCluster,
+  } = props
   const { t } = useTranslation()
-  const ref = useRef(null)
-  const graphRef = useRef<any>()
-  let graph: IAbstractGraph | null = null
+  const containerRef = useRef(null)
+  const graphRef = useRef<IAbstractGraph | null>(null)
   const location = useLocation()
   const { from, type, query } = queryString.parse(location?.search)
   const navigate = useNavigate()
@@ -117,283 +174,163 @@ const TopologyMap = ({
     e?: IG6GraphEvent
   }>({ x: -500, y: -500, e: undefined })
 
-  function getName(cfg: any) {
-    if (type === 'resource') {
-      const [left, right] = cfg?.id?.split(':')
-      const leftList = left?.split('.')
-      const leftListLength = leftList?.length
-      const leftLast = leftList?.[leftListLength - 1]
-      return `${leftLast}:${right}`
-    }
-    const list = cfg?.label?.split('.')
-    const len = list?.length
-    return list?.[len - 1]
-  }
-
-  function handleTransfer(evt, cfg) {
-    evt.defaultPrevented = true
-    evt.stopPropagation()
-    const resourceGroup = cfg?.data?.resourceGroup
-    const objParams = {
-      from,
-      type: 'kind',
-      cluster: resourceGroup?.cluster,
-      apiVersion: resourceGroup?.apiVersion,
-      kind: resourceGroup?.kind,
-      query,
-    }
-    const urlStr = queryString.stringify(objParams)
-    navigate(`/insightDetail/kind?${urlStr}`)
-  }
-
   function handleMouseEnter(evt) {
-    const model = evt?.item?.get('model')
-    graph.setItemState(evt.item, 'hoverState', true)
-    const { x, y } = graph?.getCanvasByPoint(model.x, model.y) as Point
-    const node = graph?.findById(model.id)?.getBBox()
-    if (node) {
-      setItemWidth(node?.maxX - node?.minX)
+    graphRef.current?.setItemState(evt.item, 'hoverState', true)
+    const bbox = evt.item.getBBox()
+    const point = graphRef.current?.getCanvasByPoint(bbox.centerX, bbox.minY)
+    if (bbox) {
+      setItemWidth(bbox.width)
     }
-    setHiddenButtontooltip({ x, y, e: evt })
+    setHiddenButtontooltip({ x: point.x, y: point.y - 5, e: evt })
     setTooltipopen(true)
   }
-  function handleMouseLeave(evt) {
-    graph.setItemState(evt.item, 'hoverState', false)
-    setTooltipopen(false)
-  }
 
-  function handleClickNode(cfg) {
+  const handleMouseLeave = (evt: IG6GraphEvent) => {
+    graphRef.current?.setItemState(evt.item, 'hoverState', false)
     setTooltipopen(false)
-    onTopologyNodeClick(cfg)
   }
 
-  const Card = ({ cfg }: any) => {
-    const displayName = fittingString(getName(cfg), 190, 16)
-
-    const isHighLight =
-      type === 'resource'
-        ? cfg?.resourceGroup?.name === tableName
-        : displayName === tableName
-    return (
-      <Group draggable>
-        <Rect
-          style={{
-            width: 250,
-            height: 'auto',
-            display: 'flex',
-            flexDirection: 'column',
-            justifyContent: 'center',
-            fill: isHighLight ? '#fff' : '#C6E5FF',
-            shadowColor: '#eee',
-            shadowBlur: 30,
-            radius: [8],
-            stroke: '#C6E5FF',
-          }}
-          draggable
-        >
-          <Rect
-            onClick={() => handleClickNode(cfg)}
-            style={{
-              cursor: 'pointer',
-              stroke: 'transparent',
-              fill: 'transparent',
-              display: 'flex',
-              flexDirection: 'row',
-              justifyContent: 'space-between',
-              alignItems: 'center',
-              margin: [0, 10],
-            }}
-          >
-            <Rect
-              onClick={() => handleClickNode(cfg)}
-              style={{
-                stroke: 'transparent',
-                fill: 'transparent',
-              }}
-            >
-              <NodeLabel
-                onClick={() => handleClickNode(cfg)}
-                onMouseOver={evt => handleMouseEnter(evt)}
-                onMouseLeave={evt => handleMouseLeave(evt)}
-                width={getTextSize(
-                  getName(cfg),
-                  type !== 'cluster' ? 240 : 190,
-                  16,
-                )}
-                customStyle={{
-                  fill: '#000',
-                  fontSize: 16,
-                  margin: [10, 0],
-                }}
-              >
-                {displayName}
-              </NodeLabel>
-              {typeof cfg?.data?.count === 'number' && (
-                <NodeLabel
-                  onClick={event => handleMouseEnter(event)}
-                  customStyle={{
-                    fill: '#000',
-                    fontSize: 16,
-                    margin: [5, 0],
-                  }}
-                >
-                  {`${cfg?.data?.count}`}
-                </NodeLabel>
-              )}
-            </Rect>
-            {type === 'cluster' && (
-              <Rect>
-                <Image
-                  onClick={event => handleTransfer(event, cfg)}
-                  style={{
-                    cursor: 'pointer',
-                    img: transferPng,
-                    width: 20,
-                    height: 20,
-                  }}
-                />
-              </Rect>
-            )}
-          </Rect>
-        </Rect>
-      </Group>
-    )
-  }
+  G6.registerNode(
+    'card-node',
+    {
+      draw(cfg: NodeConfig, group: IGroup) {
+        const displayName = getNodeName(cfg, type as string)
+        const count = cfg.data?.count
+        const nodeWidth = type === 'cluster' ? 240 : 200
 
-  G6.registerNode('card-node', createNodeFromReact(Card))
+        // Create main container
+        const rect = group.addShape('rect', {
+          attrs: {
+            x: 0,
+            y: 0,
+            width: nodeWidth,
+            height: 48,
+            radius: 6,
+            fill: '#ffffff',
+            stroke: '#e6f4ff',
+            lineWidth: 1,
+            shadowColor: 'rgba(0,0,0,0.06)',
+            shadowBlur: 8,
+            shadowOffsetX: 0,
+            shadowOffsetY: 2,
+            cursor: 'pointer',
+          },
+          name: 'node-container',
+        })
 
-  G6.registerEdge(
-    'custom-polyline',
-    {
-      getPath(points) {
-        const [sourcePoint, endPoint] = points
-        const x = (sourcePoint.x + endPoint.x) / 2
-        const y1 = sourcePoint.y
-        const y2 = endPoint.y
-        const path = [
-          ['M', sourcePoint.x, sourcePoint.y],
-          ['L', x, y1],
-          ['L', x, y2],
-          ['L', endPoint.x, endPoint.y],
-        ]
-        return path
-      },
-      afterDraw(cfg, group) {
-        const keyshape = group.find(ele => ele.get('name') === 'edge-shape')
-        const style = keyshape.attr()
-        const halo = group.addShape('path', {
+        // Add side accent
+        group.addShape('rect', {
           attrs: {
-            ...style,
-            lineWidth: 8,
-            opacity: 0.3,
+            x: 0,
+            y: 0,
+            width: 3,
+            height: 48,
+            radius: [3, 0, 0, 3],
+            fill: '#1677ff',
+            opacity: 0.4,
           },
-          name: 'edge-halo',
+          name: 'node-accent',
         })
-        halo.hide()
-      },
-      afterUpdate(cfg, item) {
-        const group = item.getContainer()
-        const keyshape = group.find(ele => ele.get('name') === 'edge-shape')
-        const halo = group?.find(ele => ele.get('name') === 'edge-halo')
-        const path = keyshape.attr('path')
-        halo.attr('path', path)
-      },
-      setState(name, value, item) {
-        const group = item.getContainer()
-        if (name === 'hover') {
-          const halo = group?.find(ele => ele.get('name') === 'edge-halo')
-          if (value) {
-            halo.show()
-          } else {
-            halo.hide()
-          }
-        }
-      },
-    },
-    'cubic',
-  )
 
-  useLayoutEffect(() => {
-    setTooltipopen(false)
-    if (topologyData) {
-      ;(async () => {
-        const container = document.getElementById('overviewContainer')
-        const width = container?.scrollWidth || 800
-        const height = container?.scrollHeight || 400
-        const toolbar = new G6.ToolBar()
-        if (!graph && container) {
-          // eslint-disable-next-line
-          graphRef.current = graph = new G6.Graph({
-            container,
-            width,
-            height,
-            fitCenter: true,
-            fitView: true,
-            fitViewPadding: 20,
-            plugins: [toolbar],
-            enabledStack: true,
-            modes: {
-              default: ['drag-canvas', 'drag-node', 'click-select'],
-            },
-            layout: {
-              type: 'dagre',
-              rankdir: 'LR',
-              align: 'UL',
-              nodesepFunc: () => 1,
-              ranksepFunc: () => 1,
-            },
-            defaultNode: {
-              type: 'card-node',
-              size: [240, 45],
-            },
-            defaultEdge: {
-              type: 'polyline',
-              sourceAnchor: 1,
-              targetAnchor: 0,
-              style: {
-                radius: 10,
-                offset: 20,
-                endArrow: true,
-                lineWidth: 2,
-                stroke: '#C0C5D7',
-              },
+        // Add Kubernetes icon
+        const iconSize = 32
+        const kind = cfg?.data?.resourceGroup?.kind || ''
+        group.addShape('image', {
+          attrs: {
+            x: 16,
+            y: (48 - iconSize) / 2,
+            width: iconSize,
+            height: iconSize,
+            img: ICON_MAP[kind as keyof typeof ICON_MAP] || ICON_MAP.Kubernetes,
+          },
+          name: 'node-icon',
+        })
+
+        // Add title text
+        group.addShape('text', {
+          attrs: {
+            x: 52,
+            y: 24,
+            text: fittingString(displayName || '', 100, 14),
+            fontSize: 14,
+            fontWeight: 500,
+            fill: '#1677ff',
+            cursor: 'pointer',
+            textBaseline: 'middle',
+            fontFamily:
+              '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial',
+          },
+          name: 'node-label',
+        })
+
+        if (typeof count === 'number') {
+          const textWidth = getTextWidth(`${count}`, 12)
+          const circleSize = Math.max(textWidth + 12, 20)
+          const circleX = 170
+          const circleY = 24
+
+          // Add count background
+          group.addShape('circle', {
+            attrs: {
+              x: circleX,
+              y: circleY,
+              r: circleSize / 2,
+              fill: '#f0f5ff',
             },
-            edgeStateStyles: {
-              hover: {
-                lineWidth: 6,
-              },
+            name: 'count-background',
+          })
+
+          // Add count text
+          group.addShape('text', {
+            attrs: {
+              x: circleX,
+              y: circleY,
+              text: `${count}`,
+              fontSize: 12,
+              fontWeight: 500,
+              fill: '#1677ff',
+              textAlign: 'center',
+              textBaseline: 'middle',
+              fontFamily:
+                '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial',
             },
-            nodeStateStyles: {
-              selected: {
-                stroke: '#2F54EB',
-                lineWidth: 2,
-              },
-              hoverState: {
-                lineWidth: 3,
-              },
-              clickState: {
-                stroke: '#2F54EB',
-                lineWidth: 2,
-              },
+            name: 'count-text',
+          })
+        }
+
+        if (type === 'cluster') {
+          const iconTransferSize = 20
+          group.addShape('image', {
+            attrs: {
+              x: 210,
+              y: 14,
+              width: iconTransferSize,
+              height: iconTransferSize,
+              img: transferImg,
             },
+            name: 'transfer-icon',
           })
-          graph.read(topologyData)
-          appenAutoShapeListener(graph)
-          if (topologyData?.nodes?.length < 5) {
-            graph?.zoomTo(1.5, { x: width / 2, y: height / 2 }, true, {
-              duration: 10,
-            })
-            setTimeout(() => {
-              if (graphRef?.current) {
-                graphRef?.current?.fitCenter()
-              }
-            }, 100)
-          }
-          graph.on('card-node-transfer-keyshape:click', evt => {
-            const model = evt?.item?.get('model')
+        }
+        return rect
+      },
+
+      afterDraw(cfg: NodeConfig, group: IGroup) {
+        const transferIcon = group.find(
+          element => element.get('name') === 'transfer-icon',
+        )
+        if (transferIcon) {
+          transferIcon.on('mouseenter', evt => {
+            evt.defaultPrevented = true
+            evt.stopPropagation()
+            transferIcon.attr('cursor', 'pointer')
+          })
+          transferIcon.on('mouseleave', () => {
+            transferIcon.attr('cursor', '')
+          })
+          transferIcon.on('click', evt => {
             evt.defaultPrevented = true
             evt.stopPropagation()
-            const resourceGroup = model?.data?.resourceGroup
+            const resourceGroup = cfg?.data?.resourceGroup
             const objParams = {
               from,
               type: 'kind',
@@ -405,75 +342,287 @@ const TopologyMap = ({
             const urlStr = queryString.stringify(objParams)
             navigate(`/insightDetail/kind?${urlStr}`)
           })
-          graph.on('edge:mouseenter', evt => {
-            graph.setItemState(evt.item, 'hover', true)
-          })
-          graph.on('edge:mouseleave', evt => {
-            graph.setItemState(evt.item, 'hover', false)
-          })
-          if (typeof window !== 'undefined') {
-            window.onresize = () => {
-              if (!graph || graph.get('destroyed')) return
-              if (
-                !container ||
-                !container.scrollWidth ||
-                !container.scrollHeight
-              )
-                return
-              graph.changeSize(container?.scrollWidth, container?.scrollHeight)
+        }
+      },
+    },
+    'single-node',
+  )
+
+  G6.registerEdge(
+    'running-edge',
+    {
+      afterDraw(cfg, group) {
+        const shape = group?.get('children')[0]
+        if (!shape) return
+
+        // Get the path shape
+        const startPoint = shape.getPoint(0)
+
+        // Create animated circle
+        const circle = group.addShape('circle', {
+          attrs: {
+            x: startPoint.x,
+            y: startPoint.y,
+            fill: '#1677ff',
+            r: 2,
+            opacity: 0.8,
+          },
+          name: 'running-circle',
+        })
+
+        // Add movement animation
+        circle.animate(
+          ratio => {
+            const point = shape.getPoint(ratio)
+            return {
+              x: point.x,
+              y: point.y,
             }
-          }
+          },
+          {
+            repeat: true,
+            duration: 2000,
+          },
+        )
+      },
+      setState(name, value, item) {
+        const shape = item.get('keyShape')
+        if (name === 'hover') {
+          shape?.attr('stroke', value ? '#1677ff' : '#c2c8d1')
+          shape?.attr('lineWidth', value ? 2 : 1)
+          shape?.attr('strokeOpacity', value ? 1 : 0.7)
         }
-      })()
-    }
-    return () => {
-      try {
-        if (graph) {
-          graph.destroy()
-          graphRef.current = null
+      },
+    },
+    'cubic', // Extend from built-in cubic edge
+  )
+
+  function initGraph() {
+    const container = containerRef.current
+    const width = container?.scrollWidth
+    const height = container?.scrollHeight
+    const toolbar = new G6.ToolBar()
+    return new G6.Graph({
+      container,
+      width,
+      height,
+      fitCenter: true,
+      plugins: [toolbar],
+      enabledStack: true,
+      modes: {
+        default: ['drag-canvas', 'drag-node', 'click-select'],
+      },
+      animate: true,
+      layout: {
+        type: 'dagre',
+        rankdir: 'LR',
+        align: 'UL',
+        nodesep: 10,
+        ranksep: 40,
+        nodesepFunc: () => 1,
+        ranksepFunc: () => 1,
+        controlPoints: true,
+        sortByCombo: false,
+        preventOverlap: true,
+        nodeSize: [200, 60],
+        workerEnabled: true,
+        clustering: false,
+        clusterNodeSize: [200, 60],
+        // Optimize edge layout
+        edgeFeedbackStyle: {
+          stroke: '#c2c8d1',
+          lineWidth: 1,
+          strokeOpacity: 0.5,
+          endArrow: true,
+        },
+      },
+      defaultNode: {
+        type: 'card-node',
+        size: [200, 60],
+        style: {
+          fill: '#fff',
+          stroke: '#e5e6e8',
+          radius: 4,
+          shadowColor: 'rgba(0,0,0,0.05)',
+          shadowBlur: 4,
+          shadowOffsetX: 0,
+          shadowOffsetY: 2,
+          cursor: 'pointer',
+        },
+      },
+      defaultEdge: {
+        type: 'running-edge',
+        style: {
+          radius: 10,
+          offset: 5,
+          endArrow: {
+            path: G6.Arrow.triangle(4, 6, 0),
+            d: 0,
+            fill: '#c2c8d1',
+          },
+          stroke: '#c2c8d1',
+          lineWidth: 1,
+          strokeOpacity: 0.7,
+          curveness: 0.5,
+        },
+        labelCfg: {
+          autoRotate: true,
+          style: {
+            fill: '#86909c',
+            fontSize: 12,
+          },
+        },
+      },
+      edgeStateStyles: {
+        hover: {
+          lineWidth: 2,
+        },
+      },
+      nodeStateStyles: {
+        selected: {
+          stroke: '#1677ff',
+          shadowColor: 'rgba(22,119,255,0.12)',
+          fill: '#f0f5ff',
+          opacity: 0.8,
+        },
+        hoverState: {
+          stroke: '#1677ff',
+          shadowColor: 'rgba(22,119,255,0.12)',
+          fill: '#f0f5ff',
+          opacity: 0.8,
+        },
+        clickState: {
+          stroke: '#1677ff',
+          shadowColor: 'rgba(22,119,255,0.12)',
+          fill: '#f0f5ff',
+          opacity: 0.8,
+        },
+      },
+    })
+  }
+
+  function setHightLight() {
+    graphRef.current.getNodes().forEach(node => {
+      const model: any = node.getModel()
+      const displayName = getNodeName(model, type as string)
+      const isHighLight =
+        type === 'resource'
+          ? model?.data?.resourceGroup?.name === tableName
+          : displayName === tableName
+      if (isHighLight) {
+        graphRef.current?.setItemState(node, 'selected', true)
+      }
+    })
+  }
+
+  function drawGraph(topologyData) {
+    if (topologyData) {
+      if (type === 'resource') {
+        graphRef.current?.destroy()
+        graphRef.current = null
+      }
+      if (!graphRef.current) {
+        graphRef.current = initGraph()
+        graphRef.current?.read(topologyData)
+
+        setHightLight()
+
+        graphRef.current?.on('node:click', evt => {
+          const node = evt.item
+          const model = node.getModel()
+          setTooltipopen(false)
+          graphRef.current?.getNodes().forEach(n => {
+            graphRef.current?.setItemState(n, 'selected', false)
+          })
+          graphRef.current?.setItemState(node, 'selected', true)
+          onTopologyNodeClick?.(model)
+        })
+
+        graphRef.current?.on('node:mouseenter', evt => {
+          const node = evt.item
+          if (
+            !graphRef.current
+              ?.findById(node.getModel().id)
+              ?.hasState('selected')
+          ) {
+            graphRef.current?.setItemState(node, 'hover', true)
+          }
+          handleMouseEnter(evt)
+        })
+
+        graphRef.current?.on('node:mouseleave', evt => {
+          handleMouseLeave(evt)
+        })
+
+        if (typeof window !== 'undefined') {
+          window.onresize = () => {
+            if (!graphRef.current || graphRef.current?.get('destroyed')) return
+            if (
+              !containerRef ||
+              !containerRef.current?.scrollWidth ||
+              !containerRef.current?.scrollHeight
+            )
+              return
+            graphRef.current?.changeSize(
+              containerRef?.current?.scrollWidth,
+              containerRef.current?.scrollHeight,
+            )
+          }
         }
-      } catch (error) {}
+      } else {
+        graphRef.current.clear()
+        graphRef.current.changeData(topologyData)
+        setTimeout(() => {
+          graphRef.current.fitCenter()
+        }, 100)
+        setHightLight()
+      }
     }
-    // eslint-disable-next-line
-  }, [topologyData, tableName])
+  }
+
+  useImperativeHandle(drawRef, () => ({
+    drawGraph,
+  }))
 
   return (
     <div
       className={styles.g6_topology}
       style={{ height: isResource ? 450 : 400 }}
     >
-      {topologyLoading ? (
-        <Loading />
-      ) : (
-        <div ref={ref} id="overviewContainer" className={styles.g6_overview}>
-          <div className={styles.cluster_select}>
-            <Select
-              style={{ minWidth: 100 }}
-              placeholder=""
-              value={selectedCluster}
-              onChange={handleChangeCluster}
-            >
-              {clusterOptions?.map(item => {
-                return (
-                  <Select.Option key={item}>
-                    {item === 'ALL' ? t('AllClusters') : item}
-                  </Select.Option>
-                )
-              })}
-            </Select>
-          </div>
-          {tooltipopen ? (
-            <OverviewTooltip
-              type={type as string}
-              itemWidth={itemWidth}
-              hiddenButtonInfo={hiddenButtontooltip}
-              open={tooltipopen}
-            />
-          ) : null}
+      <div className={styles.cluster_select}>
+        <Select
+          style={{ minWidth: 100 }}
+          placeholder=""
+          value={selectedCluster}
+          onChange={handleChangeCluster}
+        >
+          {clusterOptions?.map(item => {
+            return (
+              <Select.Option key={item}>
+                {item === 'ALL' ? t('AllClusters') : item}
+              </Select.Option>
+            )
+          })}
+        </Select>
+      </div>
+      <div ref={containerRef} className={styles.g6_overview}>
+        <div
+          className={styles.g6_loading}
+          style={{ display: topologyLoading ? 'block' : 'none' }}
+        >
+          <Loading />
         </div>
-      )}
+        {tooltipopen ? (
+          <OverviewTooltip
+            type={type as string}
+            itemWidth={itemWidth}
+            hiddenButtonInfo={hiddenButtontooltip}
+            open={tooltipopen}
+          />
+        ) : null}
+      </div>
     </div>
   )
-}
+})
 
 export default TopologyMap
diff --git a/ui/src/pages/insightDetail/components/topologyMap/nodeLabel.tsx b/ui/src/pages/insightDetail/components/topologyMap/nodeLabel.tsx
deleted file mode 100644
index a22ae8b9..00000000
--- a/ui/src/pages/insightDetail/components/topologyMap/nodeLabel.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Text } from '@antv/g6-react-node'
-import React from 'react'
-
-const NodeLabel = (NodeLabelProps: {
-  width?: number
-  color?: string
-  children?: string
-  onClick?: (evt) => void
-  onMouseOver?: (evt) => void
-  onMouseLeave?: (evt) => void
-  disabled?: boolean
-  marginRight?: number
-  marginLeft?: number
-  customStyle?: any
-}) => {
-  const {
-    width,
-    color = '#000',
-    children = '',
-    onClick,
-    onMouseOver,
-    onMouseLeave,
-    disabled = false,
-    customStyle = {},
-  } = NodeLabelProps
-  return (
-    <Text
-      style={{
-        width,
-        fill: color,
-        cursor: disabled ? 'not-allowed' : 'pointer',
-        ...customStyle,
-      }}
-      onClick={onClick}
-      onMouseOver={onMouseOver}
-      onMouseOut={onMouseLeave}
-    >
-      {children}
-    </Text>
-  )
-}
-
-export default NodeLabel
diff --git a/ui/src/pages/insightDetail/components/topologyMap/style.module.less b/ui/src/pages/insightDetail/components/topologyMap/style.module.less
index 08439957..9c482dda 100644
--- a/ui/src/pages/insightDetail/components/topologyMap/style.module.less
+++ b/ui/src/pages/insightDetail/components/topologyMap/style.module.less
@@ -3,24 +3,56 @@
   flex-direction: column;
   background-color: #f7faff;
   border-radius: 8px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+  min-height: 480px;
+  position: relative;
 
-  .tool_bar {
-    height: 22px;
-    padding: 20px 16px;
-    font-size: 14px;
-    font-weight: 400;
-    line-height: 22px;
-    color: rgb(0 10 26 / 68%);
+  .cluster_select {
+    position: absolute;
+    top: 0;
+    right: 0;
+    z-index: 10;
+
+    :global {
+      .ant-select {
+        min-width: 160px;
+        background-color: #fff;
+        border-radius: 4px;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+
+        &:hover {
+          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+        }
+      }
+    }
   }
 
   .g6_overview {
     position: relative;
     flex: 1;
 
-    .cluster_select {
+    .g6_loading {
       position: absolute;
+      width: 100%;
+      height: 100%;
       top: 0;
-      right: 0;
+      left: 0;
+      background: #f7faff;
+    }
+
+  }
+
+  // Loading state styles
+  .loading_container {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    text-align: center;
+
+    .loading_text {
+      margin-top: 8px;
+      color: rgba(0, 0, 0, 0.45);
     }
   }
 }
diff --git a/ui/src/pages/insightDetail/group/index.tsx b/ui/src/pages/insightDetail/group/index.tsx
index beb7879f..6b53e4e7 100644
--- a/ui/src/pages/insightDetail/group/index.tsx
+++ b/ui/src/pages/insightDetail/group/index.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import { NavLink, useLocation } from 'react-router-dom'
 import queryString from 'query-string'
 import { Breadcrumb, Tooltip } from 'antd'
@@ -39,6 +39,8 @@ const ClusterDetail = () => {
   const [selectedCluster, setSelectedCluster] = useState<any>()
   const [clusterOptions, setClusterOptions] = useState<string[]>([])
 
+  const drawRef = useRef(null)
+
   function getUrlParams() {
     const obj = {}
     Object.keys(urlParams)?.forEach(key => {
@@ -274,8 +276,9 @@ const ClusterDetail = () => {
     if (multiTopologyData) {
       const clusterKeys = Object.keys(multiTopologyData)
       setClusterOptions(['ALL', ...clusterKeys])
-      setSelectedCluster('ALL' || clusterKeys?.[0])
+      setSelectedCluster(selectedCluster || 'ALL')
     }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [multiTopologyData])
 
   useEffect(() => {
@@ -322,14 +325,16 @@ const ClusterDetail = () => {
     return urlSqlParams
   }
 
+  const selectedClusterRef = useRef()
+  selectedClusterRef.current = selectedCluster
   function onTopologyNodeClick(node) {
     const { resourceGroup } = node?.data || {}
     setTableName(resourceGroup?.kind)
     const sqlParams = generateSqlParams({
       ...resourceGroup,
-      ...(selectedCluster === 'ALL'
+      ...(selectedClusterRef?.current === 'ALL'
         ? {}
-        : { cluster: resultUrlParams?.cluster || selectedCluster }),
+        : { cluster: selectedClusterRef?.current || resultUrlParams?.cluster }),
       ...generateUrlSqlParams(),
     })
     const sqlStr = `select * from resources where ${sqlParams}`
@@ -407,6 +412,21 @@ const ClusterDetail = () => {
     }
   }
 
+  useEffect(() => {
+    let topologyData
+    if (selectedCluster) {
+      if (selectedCluster === 'ALL' && multiTopologyData) {
+        topologyData = generateAllClusterTopologyData()
+      } else {
+        topologyData =
+          multiTopologyData &&
+          selectedCluster &&
+          generateTopologyData(multiTopologyData?.[selectedCluster])
+      }
+      drawRef.current?.drawGraph(topologyData)
+    }
+  }, [multiTopologyData, selectedCluster])
+
   function renderTabPane() {
     if (currentTab === 'Topology') {
       let topologyData
@@ -422,11 +442,11 @@ const ClusterDetail = () => {
         return (
           <>
             <TopologyMap
+              ref={drawRef}
               tableName={tableName}
               selectedCluster={selectedCluster}
               handleChangeCluster={handleChangeCluster}
               clusterOptions={clusterOptions}
-              topologyData={topologyData}
               topologyLoading={topologyLoading}
               onTopologyNodeClick={onTopologyNodeClick}
             />
diff --git a/ui/src/pages/insightDetail/namespace/index.tsx b/ui/src/pages/insightDetail/namespace/index.tsx
index 85471e08..af8e2008 100644
--- a/ui/src/pages/insightDetail/namespace/index.tsx
+++ b/ui/src/pages/insightDetail/namespace/index.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import { NavLink, useLocation, useNavigate } from 'react-router-dom'
 import queryString from 'query-string'
 import { Breadcrumb, Tooltip } from 'antd'
@@ -37,7 +37,7 @@ const ClusterDetail = () => {
   const [yamlData, setYamlData] = useState('')
   const [auditList, setAuditList] = useState<any>([])
   const [auditStat, setAuditStat] = useState<any>()
-  const [tableName, setTableName] = useState('')
+  const [tableName, setTableName] = useState('Pod')
   const [breadcrumbItems, setBreadcrumbItems] = useState([])
   const [summary, setSummary] = useState<any>()
   const [currentItem, setCurrentItem] = useState<any>()
@@ -48,6 +48,8 @@ const ClusterDetail = () => {
     insightTabsList?.filter(item => item?.value !== 'Events'),
   )
 
+  const drawRef = useRef(null)
+
   useEffect(() => {
     if (urlParams?.deleted === 'true') {
       const tmp = tabList?.map(item => {
@@ -363,6 +365,16 @@ const ClusterDetail = () => {
     setSelectedCluster(val)
   }
 
+  useEffect(() => {
+    if (selectedCluster && currentTab === 'Topology') {
+      const topologyData =
+        multiTopologyData &&
+        selectedCluster &&
+        generateTopologyData(multiTopologyData?.[selectedCluster])
+      drawRef.current?.drawGraph(topologyData)
+    }
+  }, [multiTopologyData, selectedCluster, currentTab])
+
   function renderTabPane() {
     if (currentTab === 'Topology') {
       const topologyData =
@@ -373,11 +385,11 @@ const ClusterDetail = () => {
         return (
           <>
             <TopologyMap
+              ref={drawRef}
               tableName={tableName}
               selectedCluster={selectedCluster}
               handleChangeCluster={handleChangeCluster}
               clusterOptions={clusterOptions}
-              topologyData={topologyData}
               topologyLoading={topologyLoading}
               onTopologyNodeClick={onTopologyNodeClick}
             />
diff --git a/ui/src/pages/insightDetail/resource/index.tsx b/ui/src/pages/insightDetail/resource/index.tsx
index 73f6239f..0e0c8a6e 100644
--- a/ui/src/pages/insightDetail/resource/index.tsx
+++ b/ui/src/pages/insightDetail/resource/index.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import { NavLink, useLocation, useNavigate } from 'react-router-dom'
 import queryString from 'query-string'
 import { Breadcrumb, Tooltip } from 'antd'
@@ -46,6 +46,8 @@ const ClusterDetail = () => {
 
   const [tabList, setTabList] = useState(insightTabsList)
 
+  const drawRef = useRef(null)
+
   useEffect(() => {
     const initialTabList = [...insightTabsList]
     if (kind === 'Pod') {
@@ -164,7 +166,6 @@ const ClusterDetail = () => {
   const { response: summaryResponse, refetch: summaryRefetch } = useAxios({
     url: '/rest-api/v1/insight/summary',
     method: 'GET',
-    manual: true,
   })
 
   useEffect(() => {
@@ -194,12 +195,12 @@ const ClusterDetail = () => {
   } = useAxios({
     url: '/rest-api/v1/insight/topology',
     method: 'GET',
-    manual: true,
   })
 
   useEffect(() => {
     if (topologyDataResponse?.success) {
-      setMultiTopologyData(topologyDataResponse?.data)
+      const data = topologyDataResponse?.data
+      setMultiTopologyData(data)
     }
   }, [topologyDataResponse])
 
@@ -338,7 +339,9 @@ const ClusterDetail = () => {
   }, [from, key, cluster, kind, namespace, name, i18n?.language])
 
   function onTopologyNodeClick(node: any) {
-    const { resourceGroup } = node || {}
+    const {
+      data: { resourceGroup },
+    } = node || {}
     const paramsObj = {
       apiVersion: resourceGroup?.apiVersion,
       cluster: resourceGroup?.cluster,
@@ -365,6 +368,16 @@ const ClusterDetail = () => {
     setSelectedCluster(val)
   }
 
+  useEffect(() => {
+    if (selectedCluster && currentTab === 'Topology') {
+      const topologyData =
+        multiTopologyData &&
+        selectedCluster &&
+        generateResourceTopologyData(multiTopologyData?.[selectedCluster])
+      drawRef.current?.drawGraph(topologyData)
+    }
+  }, [multiTopologyData, selectedCluster, currentTab])
+
   function renderTabPane() {
     if (currentTab === 'Topology') {
       const topologyData =
@@ -374,12 +387,12 @@ const ClusterDetail = () => {
       if (topologyData?.nodes?.length > 0) {
         return (
           <TopologyMap
+            ref={drawRef}
             tableName={name as string}
             isResource={true}
             selectedCluster={selectedCluster}
             handleChangeCluster={handleChangeCluster}
             clusterOptions={clusterOptions}
-            topologyData={topologyData}
             topologyLoading={topologyLoading}
             onTopologyNodeClick={onTopologyNodeClick}
           />
diff --git a/ui/src/utils/tools.ts b/ui/src/utils/tools.ts
index 58dd73af..4bf21dc6 100644
--- a/ui/src/utils/tools.ts
+++ b/ui/src/utils/tools.ts
@@ -79,7 +79,7 @@ export function generateTopologyData(data) {
   for (const key in data) {
     const relationships = data[key].relationship
     for (const targetKey in relationships) {
-      const relationType = relationships[targetKey]
+      const relationType = relationships?.[targetKey]
       if (relationType === 'child') {
         addEdge(key, targetKey)
       } else if (relationType === 'parent') {
@@ -96,7 +96,13 @@ export function generateResourceTopologyData(data) {
   const edges = []
 
   const addNode = (id, label, resourceGroup) => {
-    nodes.push({ id, label, resourceGroup })
+    nodes.push({
+      id,
+      label,
+      data: {
+        resourceGroup,
+      },
+    })
   }
 
   const uniqueEdges = new Set()
@@ -109,16 +115,16 @@ export function generateResourceTopologyData(data) {
   }
 
   Object.keys(data).forEach(key => {
-    const entity = data[key]
+    const entity = data?.[key]
 
-    addNode(key, key.split(':')[1].split('.')[1], entity?.resourceGroup)
+    addNode(key, key?.split(':')?.[1]?.split('.')?.[1], entity?.resourceGroup)
 
-    entity.children.forEach(child => {
+    entity?.children?.forEach(child => {
       addEdge(key, child)
     })
 
-    if (entity.Parents) {
-      entity.Parents.forEach(parent => {
+    if (entity?.Parents) {
+      entity?.Parents?.forEach(parent => {
         addEdge(parent, key)
       })
     }