diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index 4922182..ba84b7a 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -9,6 +9,7 @@ import { Link2, type LucideIcon, Radio, + RotateCcw, RefreshCw, Server, Waypoints, @@ -35,6 +36,7 @@ const FOLD_STEP = 12; type Kind = CoverageGraphNode["kind"]; type Zone = "internet" | "boundary" | "internal"; +type CoverageViewMode = "asset" | "network"; const zoneMeta: Record = { internet: { @@ -250,7 +252,11 @@ function graphLabel(n: CoverageGraphNode): string { return n.label; } -function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDatum[] { +function toG6Nodes( + renderNodes: RenderNode[], + activeKeys: Set, + viewMode: CoverageViewMode, +): G6NodeDatum[] { const buckets: Record = { internet: [], boundary: [], internal: [] }; for (const rn of renderNodes) { buckets[rn.fold ? "boundary" : zoneOf(rn.node)].push(rn); @@ -259,11 +265,20 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDa const virtualHeight = Math.max(760, maxCount * 42); const positions = new Map(); const xByZone: Record = { internet: 150, boundary: 600, internal: 1050 }; + const xByAssetKind: Partial> = { + company: 120, + root_domain: 180, + subdomain: 400, + ip: 400, + service: 680, + app: 680, + endpoint: 1000, + }; for (const zone of Object.keys(buckets) as Zone[]) { const items = buckets[zone]; items.forEach((rn, index) => { positions.set(rn.key, { - x: xByZone[zone], + x: viewMode === "network" ? xByZone[zone] : (xByAssetKind[rn.kind] ?? 600), y: 70 + ((index + 1) * (virtualHeight - 140)) / (items.length + 1), }); }); @@ -533,6 +548,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const [mapping, setMapping] = React.useState(false); const [mappingConversationId, setMappingConversationId] = React.useState(null); const [lastSyncedAt, setLastSyncedAt] = React.useState(null); + const [viewMode, setViewMode] = React.useState("asset"); + const [viewport, setViewport] = React.useState<{ zoom: number; position: [number, number] }>({ + zoom: 1, + position: [0, 0], + }); const containerRef = React.useRef(null); const graphRef = React.useRef(null); @@ -544,6 +564,24 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }); const graphDataRef = React.useRef(null); + const syncViewport = React.useCallback(() => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + const position = graph.getPosition(); + setViewport({ zoom: graph.getZoom(), position: [position[0], position[1]] }); + }, []); + + const resetView = React.useCallback(async () => { + setActivePath([]); + setSelectedAsset(null); + setSelectedFoldId(null); + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + await graph.zoomTo(1); + await graph.fitCenter(); + syncViewport(); + }, [syncViewport]); + const fetchGraph = React.useCallback(() => { setLoading(true); api @@ -631,13 +669,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。 const sig = React.useMemo( () => - `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}`, - [activePath, renderNodes, renderEdges], + `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}|${viewMode}`, + [activePath, renderNodes, renderEdges, viewMode], ); // 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。 gDataRef.current = { - nodes: toG6Nodes(renderNodes, new Set(activePath)), + nodes: toG6Nodes(renderNodes, new Set(activePath), viewMode), edges: renderEdges.map((e) => ({ // The API stores child → parent containment. Topology reads naturally // from internet entry → boundary service → internal asset, so reverse @@ -665,12 +703,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // Keep the zone columns at their assigned coordinates. fitView rescales // to the bounds of the current nodes, so an empty zone causes the // remaining nodes to slide into the next column. - return active.length > 0 ? graph.focusElement(active) : graph.fitCenter(); + const result = active.length > 0 ? graph.focusElement(active) : graph.fitCenter(); + return result.then(() => syncViewport()); }) .catch((err) => { if (!graph.destroyed) console.error("[coverage-graph] render:", err); }); - }, []); + }, [syncViewport]); // 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。 React.useEffect(() => { @@ -717,6 +756,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }, behaviors: ["drag-element", "drag-canvas", "zoom-canvas"], }); + graph.on("aftertransform", syncViewport); graph.on("node:click", (evt: unknown) => { const id = (evt as { target?: { id?: string } }).target?.id; if (!id) return; @@ -738,7 +778,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera cursor = parents.get(cursor); } setActivePath(lineage); - void graph?.focusElement(lineage); + void graph?.focusElement(lineage).then(() => syncViewport()); }); graphRef.current = graph; applyData(); @@ -754,7 +794,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera graph?.destroy(); graphRef.current = null; }; - }, [applyData]); + }, [applyData, syncViewport]); // 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。 // biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖 @@ -784,23 +824,38 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera return (
-
- {(Object.keys(zoneMeta) as Zone[]).map((zone, index) => ( + {viewMode === "network" ? ( +
-
-
{zoneMeta[zone].label}
-
{zoneMeta[zone].description}
-
+ {(Object.keys(zoneMeta) as Zone[]).map((zone, index) => ( +
+
+
{zoneMeta[zone].label}
+
{zoneMeta[zone].description}
+
+
+ ))}
- ))} -
+
+ ) : ( +
+
资产关系图
+
按资产层级展示关联关系
+
+ )}
{/* 图例 + 统计 + 刷新(叠加层) */} -
+
@@ -833,9 +888,39 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera ) : ( {loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"} )} - + +
+
+
+ +