diff --git a/db/task_scope.go b/db/task_scope.go index e93f79d..9fb7aa8 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -570,7 +570,12 @@ WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID) link(n.Key, companyKey(n.CompanyID)) } case "service": - link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain])) + host, _ := hostPortOf(n) + link(n.Key, firstOf( + subByDomain[n.Domain], subByDomain[host], + ipByAddr[n.IP], ipByAddr[host], + rootByDomain[n.RootDomain], + )) case "endpoint": host, port := hostPortOf(n) parent := firstOf( diff --git a/server/server.go b/server/server.go index f43b3ba..e9e2555 100644 --- a/server/server.go +++ b/server/server.go @@ -766,6 +766,7 @@ 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) + mux.HandleFunc("POST /api/tasks/{id}/asset-mapping", s.taskAssetMapping) mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs) mux.HandleFunc("POST /api/tasks/{id}/assets", s.attachTaskAssets) mux.HandleFunc("DELETE /api/tasks/{id}/assets/{assetID}", s.detachTaskAsset) diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go new file mode 100644 index 0000000..6703d0e --- /dev/null +++ b/server/task_asset_mapping.go @@ -0,0 +1,39 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +// 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. +func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, "task not found") + return + } + if s.m.pg == nil || s.chatAgentRef() == nil { + writeErr(w, http.StatusServiceUnavailable, s.chatUnavailableReason()) + return + } + if agentInfo, err := s.m.pg.GetAgentByKey("asset_mapper"); err != nil || agentInfo == nil || !agentInfo.Enabled { + writeErr(w, http.StatusServiceUnavailable, "资产梳理 Agent 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + description := strings.TrimSpace(t.Description) + 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) + } + } + s.StartTriggeredRun("asset_mapper", fmt.Sprintf("资产梳理 · 任务 #%d", taskID), message, taskID, false, description, t.Goal) + writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "agent_key": "asset_mapper"}) +} 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 ec35766..7540f52 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 @@ -57,7 +57,9 @@ const zoneMeta: Record(null); const [activePath, setActivePath] = React.useState([]); const [loading, setLoading] = React.useState(true); + const [mapping, setMapping] = React.useState(false); const [lastSyncedAt, setLastSyncedAt] = React.useState(null); const containerRef = React.useRef(null); @@ -530,6 +533,16 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera .finally(() => setLoading(false)); }, [taskId, coverageEnabled]); + const remapAssets = React.useCallback(async () => { + setMapping(true); + try { + await api.startAssetMapping(taskId); + } finally { + setMapping(false); + fetchGraph(); + } + }, [fetchGraph, taskId]); + React.useEffect(() => { fetchGraph(); }, [fetchGraph]); @@ -619,6 +632,16 @@ 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) => { @@ -718,7 +741,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
{/* 图例 + 统计 + 刷新(叠加层) */} -
+
@@ -745,8 +768,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera ) : ( {loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"} )} -
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 78d240c..2fc4aeb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -451,6 +451,7 @@ 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 }>(`/tasks/${id}/asset-mapping`, {}), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), // ---- 目标管理(总览)----