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 &&
+ - -
+ {viewMode === "network" && ( + + )} +
} + {panelCollapsed && }
+ {!panelCollapsed && <>
{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}`), // ---- 目标管理(总览)----