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