From 6b0acf20f6ab0571fd6b0f47482cd3968ab6c477 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:50:52 +0800 Subject: [PATCH] fix: bound asset mapping context and use layered topology --- server/task_asset_mapping.go | 29 +++++++++- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 53 +++++++------------ 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go index 6703d0e..a8a2b95 100644 --- a/server/task_asset_mapping.go +++ b/server/task_asset_mapping.go @@ -30,8 +30,33 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { message := fmt.Sprintf("请重新梳理任务 #%d 的资产关系。只使用当前任务及其直接关联任务的真实资产数据,按互联网入口、边界服务、内网业务三层说明关系;不要探测、不要修改资产库,也不要把没有证据的连接描述成已验证通路。", taskID) if as := s.m.Assets(); as != nil { if graph, err := as.BuildCoverageGraph(taskID, t.ExpID); err == nil { - snapshot, _ := json.Marshal(map[string]any{"nodes": graph.Nodes, "edges": graph.Edges}) - message += "\n\n页面刚读取的任务资产图真实快照如下;优先依据这份快照解释,避免把全局资产误当成本任务资产:\n" + string(snapshot) + type nodeSample struct { + Key, Kind, Label, Domain, IP string + Port int + Tested, InScope bool + } + counts := map[string]int{} + sample := make([]nodeSample, 0, 60) + for _, node := range graph.Nodes { + counts[node.Kind]++ + if len(sample) < 60 { + sample = append(sample, nodeSample{ + Key: node.Key, Kind: node.Kind, Label: node.Label, Domain: node.Domain, + IP: node.IP, Port: node.Port, Tested: node.Tested, InScope: node.InScope, + }) + } + } + edges := graph.Edges + truncatedEdges := len(edges) > 80 + if truncatedEdges { + edges = edges[:80] + } + snapshot, _ := json.Marshal(map[string]any{ + "nodes_total": len(graph.Nodes), "edges_total": len(graph.Edges), + "counts_by_kind": counts, "node_sample": sample, "edge_sample": edges, + "truncated": len(graph.Nodes) > len(sample) || truncatedEdges, + }) + message += "\n\n页面刚读取的任务资产图真实摘要如下;只依据摘要和任务范围解释,避免把全局资产误当成本任务资产。未列出的节点不要逐一补写:\n" + string(snapshot) } } s.StartTriggeredRun("asset_mapper", fmt.Sprintf("资产梳理 · 任务 #%d", taskID), message, taskID, false, description, t.Goal) 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 7540f52..ec12979 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 @@ -29,8 +29,8 @@ import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGr import { cn } from "@/lib/utils"; // 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。 -const FOLD_LIMIT = 20; -const FOLD_STEP = 20; +const FOLD_LIMIT = 12; +const FOLD_STEP = 12; type Kind = CoverageGraphNode["kind"]; @@ -539,9 +539,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera await api.startAssetMapping(taskId); } finally { setMapping(false); - fetchGraph(); } - }, [fetchGraph, taskId]); + }, [taskId]); React.useEffect(() => { fetchGraph(); @@ -566,8 +565,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera gDataRef.current = { nodes: toG6Nodes(renderNodes, new Set(activePath)), edges: renderEdges.map((e) => ({ - source: e.src, - target: e.dst, + // The API stores child → parent containment. Topology reads naturally + // from internet entry → boundary service → internal asset, so reverse + // the visual arrow without changing the source graph or lineage logic. + source: e.dst, + target: e.src, active: activePath.includes(e.src) && activePath.includes(e.dst), })), }; @@ -579,9 +581,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const graph = graphRef.current; if (!graph || graph.destroyed) return; graph.setData(gDataRef.current); - // render() 异步跑 d3-force 布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的 - // context 上访问 transform 抛错(见 runtime/layout transformDataAfterLayout)。这是纯 - // teardown 竞态,吞掉它,不影响功能;真正的渲染错误(图未销毁)仍打日志。 + // render() 异步跑布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的 + // context 上访问 transform 抛错。这是纯 teardown 竞态,吞掉它,不影响功能。 void graph.render().catch((err) => { if (!graph.destroyed) console.error("[coverage-graph] render:", err); }); @@ -631,33 +632,15 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }, }, layout: { - type: "d3-force", - x: { - strength: 0.28, - x: (d: unknown) => { - const node = renderMapRef.current.get(nd(d).id); - const width = containerRef.current?.clientWidth ?? 900; - const zone = node && !node.fold ? zoneOf(node.node) : "boundary"; - return width * (zone === "internet" ? 0.17 : zone === "boundary" ? 0.5 : 0.83); - }, - }, - y: { strength: 0.04, y: () => (containerRef.current?.clientHeight ?? 600) * 0.52 }, - collide: { radius: (d: unknown) => (nd(d).size ? nd(d).size : 20) + 8 }, - link: { - distance: (edge: unknown) => { - // 顶层(公司/根域名)离子节点远一点,叶子近一点。edge.source 可能是 id 或已解析节点。 - const s = (edge as { source: string | { id?: string } }).source; - const srcId = typeof s === "string" ? s : (s?.id ?? ""); - const src = renderMapRef.current.get(srcId); - const k = src && !src.fold ? src.kind : "endpoint"; - return k === "company" || k === "root_domain" ? 120 : 60; - }, - }, - manyBody: { - strength: (d: unknown) => (nd(d).kind === "endpoint" || nd(d).fold ? -60 : -200), - }, + // A layered DAG is more legible for network topology than a force + // cloud: parents stay on the left and services/assets flow right. + type: "dagre", + rankdir: "LR", + ranker: "network-simplex", + nodesep: 34, + ranksep: 170, }, - behaviors: ["drag-element-force", "drag-canvas", "zoom-canvas"], + behaviors: ["drag-element", "drag-canvas", "zoom-canvas"], }); graph.on("node:click", (evt: unknown) => { const id = (evt as { target?: { id?: string } }).target?.id;