@@ -833,9 +888,39 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
) : (
{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}
)}
-
+
+ {
+ setViewMode("asset");
+ void resetView();
+ }}
+ >
+ 资产关系图
+
+ {
+ setViewMode("network");
+ void resetView();
+ }}
+ >
+ 网络拓扑
From 7d5c7101b758ba52338de312b980a3a3968084f3 Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:25:28 +0800
Subject: [PATCH 16/17] feat: simplify network topology asset nodes
---
.../tasks/detail/_tabs/coverage-graph-tab.tsx | 88 +++++++++++++++----
1 file changed, 69 insertions(+), 19 deletions(-)
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 ba84b7a..7010366 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
@@ -113,6 +113,26 @@ const KIND_ICON: Record
= {
),
};
+type DatabaseEngine = "mysql" | "sqlserver" | "postgres" | "redis" | "database";
+
+const DATABASE_ICON: Record = {
+ mysql: svgUri('MY', true),
+ sqlserver: svgUri('MS', true),
+ postgres: svgUri('PG', true),
+ redis: svgUri('RD', true),
+ database: svgUri(''),
+};
+
+function databaseEngine(node: CoverageGraphNode): DatabaseEngine | null {
+ const raw = `${node.service_type ?? ""} ${node.label} ${node.page_title ?? ""}`.toLowerCase();
+ if (/mysql|mariadb/.test(raw)) return "mysql";
+ if (/sql\s*server|mssql|microsoft sql/.test(raw)) return "sqlserver";
+ if (/postgres|postgresql/.test(raw)) return "postgres";
+ if (/redis/.test(raw)) return "redis";
+ if (/database|数据库|dbms|oracle|mongodb|mongo|clickhouse/.test(raw)) return "database";
+ return null;
+}
+
const FOLD_ICON = svgUri(
'',
true,
@@ -218,6 +238,7 @@ type G6NodeDatum = {
lbl: string;
size: number;
active: boolean;
+ iconSrc: string;
x: number;
y: number;
// G6 v5 reads the initial position from style.x/style.y. Keeping the
@@ -234,7 +255,16 @@ function trunc(s: string, n = 26): string {
// 图里的节点文案:服务不显示完整 URL,只显示 端口·标题·状态码;端点只显示 path。
// 其余类型沿用后端给的 label。
-function graphLabel(n: CoverageGraphNode): string {
+function graphLabel(n: CoverageGraphNode, viewMode: CoverageViewMode = "asset"): string {
+ if (viewMode === "network") {
+ if (n.kind === "endpoint") return "";
+ if (n.kind === "service") {
+ const engine = databaseEngine(n);
+ const host = n.domain || n.ip || n.label;
+ return engine ? `${engine.toUpperCase()} · ${host}${n.port ? `:${n.port}` : ""}` : `${host}${n.port ? `:${n.port}` : ""}`;
+ }
+ return n.label || n.domain || n.ip || n.app_name || kindMeta[n.kind].label;
+ }
if (n.kind === "service") {
const parts: string[] = [];
if (n.port) parts.push(`:${n.port}`);
@@ -292,9 +322,10 @@ function toG6Nodes(
fold: true,
tested: false,
inScope: false,
- lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
+ lbl: viewMode === "network" && rn.kind === "endpoint" ? "" : `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
size: 24,
active: activeKeys.has(rn.key),
+ iconSrc: FOLD_ICON,
style: { x: position.x, y: position.y },
...position,
};
@@ -305,9 +336,12 @@ function toG6Nodes(
fold: false,
tested: rn.node.tested,
inScope: rn.node.in_scope,
- lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
+ lbl: trunc(graphLabel(rn.node, viewMode) || kindMeta[rn.kind].label),
size: kindMeta[rn.kind].size,
active: activeKeys.has(rn.key),
+ iconSrc: viewMode === "network" && databaseEngine(rn.node)
+ ? DATABASE_ICON[databaseEngine(rn.node) as DatabaseEngine]
+ : KIND_ICON[rn.kind],
style: { x: position.x, y: position.y },
...position,
};
@@ -666,17 +700,29 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
[data, expanded],
);
+ const displayNodes = React.useMemo(
+ () => viewMode === "network"
+ ? renderNodes.filter((node) => node.kind !== "endpoint")
+ : renderNodes,
+ [renderNodes, viewMode],
+ );
+ const displayNodeKeys = React.useMemo(() => new Set(displayNodes.map((node) => node.key)), [displayNodes]);
+ const displayEdges = React.useMemo(
+ () => renderEdges.filter((edge) => displayNodeKeys.has(edge.src) && displayNodeKeys.has(edge.dst)),
+ [displayNodeKeys, renderEdges],
+ );
+
// 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。
const sig = React.useMemo(
() =>
- `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}|${viewMode}`,
- [activePath, renderNodes, renderEdges, viewMode],
+ `${displayNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${displayEdges.length}|${activePath.join(",")}|${viewMode}`,
+ [activePath, displayEdges, displayNodes, viewMode],
);
// 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。
gDataRef.current = {
- nodes: toG6Nodes(renderNodes, new Set(activePath), viewMode),
- edges: renderEdges.map((e) => ({
+ nodes: toG6Nodes(displayNodes, new Set(activePath), viewMode),
+ edges: displayEdges.map((e) => ({
// 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.
@@ -686,7 +732,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
})),
};
const rmap = new Map();
- for (const rn of renderNodes) rmap.set(rn.key, rn);
+ for (const rn of displayNodes) rmap.set(rn.key, rn);
renderMapRef.current = rmap;
const applyData = React.useCallback(() => {
@@ -730,7 +776,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
stroke: (d: unknown) => nodeStroke(nd(d)),
lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5),
lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]),
- iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
+ iconSrc: (d: unknown) => nd(d).iconSrc,
iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55),
iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55),
labelText: (d: unknown) => nd(d).lbl,
@@ -924,7 +970,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
- {(Object.keys(kindMeta) as Kind[]).map((k) => {
+ {(Object.keys(kindMeta) as Kind[]).filter((k) => viewMode === "asset" || k !== "endpoint").map((k) => {
const m = kindMeta[k];
const Icon = m.icon;
return (
@@ -952,16 +998,20 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
范围外
-
- {(Object.keys(zoneMeta) as Zone[]).map((zone) => (
-
-
- {zoneMeta[zone].label}
-
- ))}
-
+ {viewMode === "network" && (
+
+ {(Object.keys(zoneMeta) as Zone[]).map((zone) => (
+
+
+ {zoneMeta[zone].label}
+
+ ))}
+
+ )}
- 点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。
+ {viewMode === "network"
+ ? "网络拓扑只展示资产节点和所在区域;数据库资产使用对应数据库标识。"
+ : "点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。"}
From 21646d5d2955ae0648ea2401e079461df9eb7864 Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Wed, 28 Oct 2026 23:19:53 +0800
Subject: [PATCH 17/17] feat: separate topology mapping from asset graph
---
server/server.go | 5 ++
server/task_asset_mapping.go | 42 ++++++---
.../tasks/detail/_tabs/coverage-graph-tab.tsx | 87 +++++++++++++------
web/src/lib/api.ts | 8 +-
4 files changed, 99 insertions(+), 43 deletions(-)
diff --git a/server/server.go b/server/server.go
index 788607a..da2296b 100644
--- a/server/server.go
+++ b/server/server.go
@@ -766,6 +766,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/task-archives/delete/batch", s.deleteTaskArchivesBatch)
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
+ // Asset relationships are read directly from the asset store. This action
+ // only runs the topology explanation and produces the asset/network reports.
+ mux.HandleFunc("POST /api/tasks/{id}/topology-mapping", s.taskAssetMapping)
+ mux.HandleFunc("GET /api/tasks/{id}/topology-mapping/status", s.taskAssetMappingStatus)
+ // Keep the old paths for clients from earlier builds.
mux.HandleFunc("POST /api/tasks/{id}/asset-mapping", s.taskAssetMapping)
mux.HandleFunc("GET /api/tasks/{id}/asset-mapping/status", s.taskAssetMappingStatus)
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go
index 79b77d3..1ce5f31 100644
--- a/server/task_asset_mapping.go
+++ b/server/task_asset_mapping.go
@@ -9,9 +9,10 @@ import (
"github.com/Autumn-27/artex/db"
)
-// taskAssetMapping starts a visible asset-mapper conversation for the task and
-// then lets the graph refresh independently. The graph itself remains sourced
-// from the database; the conversation is the audit trail for the explanation.
+// taskAssetMapping starts a visible topology-mapping conversation for the task.
+// The asset relationship graph remains sourced directly from the database and
+// does not depend on this conversation. The conversation is the audit trail for
+// the two human-readable reports (asset relationships + network topology).
func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
@@ -23,11 +24,22 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) {
return
}
if agentInfo, err := s.m.pg.GetAgentByKey("asset_mapper"); err != nil || agentInfo == nil || !agentInfo.Enabled {
- writeErr(w, http.StatusServiceUnavailable, "资产梳理 Agent 未启用")
+ writeErr(w, http.StatusServiceUnavailable, "网络拓扑梳理 Agent 未启用")
return
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
- message := fmt.Sprintf("请重新梳理任务 #%d 的资产关系。只使用当前任务及其直接关联任务的真实资产数据,按互联网入口、边界服务、内网业务三层说明关系;不要探测、不要修改资产库,也不要把没有证据的连接描述成已验证通路。", taskID)
+ message := fmt.Sprintf(`请重新梳理任务 #%d 的网络拓扑,并在同一次会话中输出两份相互独立的结果。
+
+输出必须严格使用下面两个一级标题,不能合并成一份:
+## 资产关系结果
+- 只根据当前任务及其直接关联任务的真实资产数据,保留资产库中的层级和归属关系。
+- 这部分是资产关系图的说明,不要声称本次 Agent 改写了资产关系,也不要补充摘要中没有的资产。
+
+## 网络拓扑结果
+- 只整理互联网入口、边界服务、内网业务三层网络关系,并说明每个节点所属区域。
+- 只展示有真实资产证据的连接;数据库节点标注可识别的数据库类型。
+
+两份结果都只能使用当前任务及其直接关联任务的真实资产数据;不要探测目标、不要修改资产库,也不要把没有证据的连接描述成已验证通路。`, taskID)
if as := s.m.Assets(); as != nil {
if graph, err := as.BuildCoverageGraph(taskID, t.ExpID); err == nil {
type nodeSample struct {
@@ -59,16 +71,16 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) {
message += "\n\n页面刚读取的任务资产图真实摘要如下;只依据摘要和任务范围解释,避免把全局资产误当成本任务资产。未列出的节点不要逐一补写:\n" + string(snapshot)
}
}
- title := fmt.Sprintf("资产梳理 · 任务 #%d", taskID)
+ title := fmt.Sprintf("资产与网络梳理 · 任务 #%d", taskID)
conversation, err := s.m.pg.CreateConversation("asset_mapper", title, nil)
if err != nil || conversation == nil {
- writeErr(w, http.StatusInternalServerError, "创建资产梳理会话失败")
+ writeErr(w, http.StatusInternalServerError, "创建拓扑梳理会话失败")
return
}
if _, err := s.m.pg.AppendConvActivity(conversation.ID, db.Activity{
Worker: "asset_mapper", Kind: "user", Summary: title, Detail: message,
}); err != nil {
- writeErr(w, http.StatusInternalServerError, "写入资产梳理会话失败")
+ writeErr(w, http.StatusInternalServerError, "写入拓扑梳理会话失败")
return
}
busyKey := s.convBusyKey(conversation.ID)
@@ -92,17 +104,23 @@ func (s *Server) taskAssetMappingStatus(w http.ResponseWriter, r *http.Request)
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
- title := fmt.Sprintf("资产梳理 · 任务 #%s", t.ID)
+ title := fmt.Sprintf("资产与网络梳理 · 任务 #%s", t.ID)
+ legacyTitle := fmt.Sprintf("资产梳理 · 任务 #%s", t.ID)
for _, conversation := range conversations {
- if conversation.AgentKey != "asset_mapper" || conversation.Title != title {
+ if conversation.AgentKey != "asset_mapper" || (conversation.Title != title && conversation.Title != legacyTitle) {
continue
}
busyKey := s.convBusyKey(conversation.ID)
s.chatMu.Lock()
running := s.chatBusy[busyKey]
s.chatMu.Unlock()
- writeJSON(w, http.StatusOK, map[string]any{"running": running, "conversation_id": conversation.ID})
- return
+ // A completed conversation is historical evidence, not an active run.
+ // Keep looking so a newer run is not hidden by an older one, and avoid
+ // making the client poll a completed conversation after page reload.
+ if running {
+ writeJSON(w, http.StatusOK, map[string]any{"running": true, "conversation_id": conversation.ID})
+ return
+ }
}
writeJSON(w, http.StatusOK, map[string]any{"running": false})
}
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 7010366..efea1d3 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
@@ -5,6 +5,8 @@ import type { Graph as G6Graph } from "@antv/g6";
import {
AppWindow,
Building2,
+ ChevronDown,
+ ChevronUp,
Globe,
Link2,
type LucideIcon,
@@ -581,8 +583,10 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
const [loading, setLoading] = React.useState(true);
const [mapping, setMapping] = React.useState(false);
const [mappingConversationId, setMappingConversationId] = React.useState
(null);
+ const [networkReportAt, setNetworkReportAt] = React.useState(null);
const [lastSyncedAt, setLastSyncedAt] = React.useState(null);
const [viewMode, setViewMode] = React.useState("asset");
+ const [panelCollapsed, setPanelCollapsed] = React.useState(false);
const [viewport, setViewport] = React.useState<{ zoom: number; position: [number, number] }>({
zoom: 1,
position: [0, 0],
@@ -634,10 +638,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
.finally(() => setLoading(false));
}, [taskId, coverageEnabled]);
- const remapAssets = React.useCallback(async () => {
+ const remapNetwork = React.useCallback(async () => {
setMapping(true);
+ setNetworkReportAt(null);
try {
- const run = await api.startAssetMapping(taskId);
+ const run = await api.startTopologyMapping(taskId);
setMappingConversationId(run.conversation_id);
} catch {
setMapping(false);
@@ -654,7 +659,9 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
if (!state.running) {
setMapping(false);
setMappingConversationId(null);
- fetchGraph();
+ // The asset relationship graph is read directly from the asset store;
+ // completing the network report must not rebuild or replace it.
+ setNetworkReportAt(new Date());
}
} catch {
// Keep the spinner while the conversation endpoint is temporarily unavailable.
@@ -666,7 +673,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
cancelled = true;
window.clearInterval(timer);
};
- }, [fetchGraph, mappingConversationId]);
+ }, [mappingConversationId]);
React.useEffect(() => {
fetchGraph();
@@ -675,7 +682,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
React.useEffect(() => {
let cancelled = false;
api
- .assetMappingStatus(taskId)
+ .topologyMappingStatus(taskId)
.then((status) => {
if (cancelled) return;
setMapping(status.running);
@@ -883,11 +890,14 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
-
-
{zoneMeta[zone].label}
-
{zoneMeta[zone].description}
-
+ />
+ ))}
+
+
+ {(Object.keys(zoneMeta) as Zone[]).map((zone) => (
+
+
{zoneMeta[zone].label}
+
{zoneMeta[zone].description}
))}
@@ -901,22 +911,33 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
{/* 图例 + 统计 + 刷新(叠加层) */}
-
+
-
+
-
-
资产梳理 Agent
+
+
+ {viewMode === "network" ? "网络拓扑梳理 Agent" : "资产关系图"}
+
- {mapping
- ? "正在梳理资产关系…"
+ {viewMode === "network"
+ ? mapping
+ ? "正在梳理网络拓扑,并生成两份结果…"
+ : networkReportAt
+ ? `最近梳理 ${networkReportAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`
+ : "网络拓扑等待梳理"
: loading
- ? "正在更新资产关系…"
+ ? "正在读取资产库关系…"
: lastSyncedAt
- ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`
- : "等待首次梳理"}
+ ? `资产库实时数据 · ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`
+ : "等待读取资产库"}
@@ -934,17 +955,28 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
) : (
{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}
)}
-
+ {!panelCollapsed &&
+ setPanelCollapsed(true)} title="收起面板">
+
+ 收起
+
void resetView()} title="重置视图">
重置视图
- void remapAssets()} disabled={mapping} title="重新梳理资产">
-
- {mapping ? "正在梳理…" : "重新梳理"}
-
-
+ {viewMode === "network" && (
+
void remapNetwork()} disabled={mapping} title="重新梳理网络拓扑">
+
+ {mapping ? "正在梳理网络…" : "梳理网络拓扑"}
+
+ )}
+
}
+ {panelCollapsed &&
setPanelCollapsed(false)} title="展开面板">
+
+ 展开
+ }
+ {!panelCollapsed && <>
{viewMode === "network"
- ? "网络拓扑只展示资产节点和所在区域;数据库资产使用对应数据库标识。"
- : "点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。"}
+ ? "网络拓扑梳理会话会输出资产关系结果和网络拓扑结果;图中只展示有真实资产证据的节点及区域。"
+ : "资产关系图直接读取资产库,不需要额外梳理;点击节点查看详情并高亮关系链。灰色「⋯」可展开更多资产。"}
+ >}
{selectedAsset && activePath.length > 0 && data && (
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index cfe1d80..39b48f3 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -451,10 +451,10 @@ export const api = {
}>(`/tasks/${id}/coverage`),
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
taskCoverageGraph: (id: string) => get
(`/tasks/${id}/coverage-graph`),
- startAssetMapping: (id: string) =>
- post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/asset-mapping`, {}),
- assetMappingStatus: (id: string) =>
- get<{ running: boolean; conversation_id?: number }>(`/tasks/${id}/asset-mapping/status`),
+ startTopologyMapping: (id: string) =>
+ post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/topology-mapping`, {}),
+ topologyMappingStatus: (id: string) =>
+ get<{ running: boolean; conversation_id?: number }>(`/tasks/${id}/topology-mapping/status`),
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
usageStats: (days = 365) => get(`/tokens/usage?days=${days}`),
// ---- 目标管理(总览)----