mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-05 03:53:49 +08:00
feat: separate topology mapping from asset graph
This commit is contained in:
1 parent
7d5c7101b7
commit
21646d5d29
4 files changed
+99
-43
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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<number | null>(null);
|
||||
const [networkReportAt, setNetworkReportAt] = React.useState<Date | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = React.useState<Date | null>(null);
|
||||
const [viewMode, setViewMode] = React.useState<CoverageViewMode>("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
|
||||
<div
|
||||
key={zone}
|
||||
className={cn("relative border-r border-slate-200/80 dark:border-slate-800/80", zoneMeta[zone].className, index === 2 && "border-r-0")}
|
||||
>
|
||||
<div className="absolute top-3 left-4">
|
||||
<div className="text-foreground/80 text-sm font-semibold">{zoneMeta[zone].label}</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">{zoneMeta[zone].description}</div>
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-3 left-0 z-10 grid w-full grid-cols-3 px-4">
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone) => (
|
||||
<div key={zone} className="min-w-0">
|
||||
<div className="text-foreground/80 text-sm font-semibold">{zoneMeta[zone].label}</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">{zoneMeta[zone].description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -901,22 +911,33 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
<div ref={containerRef} className="relative z-10 h-full w-full pt-12" />
|
||||
|
||||
{/* 图例 + 统计 + 刷新(叠加层) */}
|
||||
<div className="bg-card/95 pointer-events-auto absolute bottom-3 left-3 z-20 flex max-h-[calc(100%-1.5rem)] max-w-[480px] flex-col gap-2.5 overflow-auto rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
|
||||
<div className={cn(
|
||||
"bg-card/95 pointer-events-auto absolute bottom-3 left-3 z-20 rounded-lg border shadow-sm backdrop-blur",
|
||||
panelCollapsed
|
||||
? "flex items-center gap-2 px-2.5 py-2 text-xs"
|
||||
: "flex w-[min(480px,calc(100%-1.5rem))] max-h-[calc(100%-1.5rem)] flex-col gap-2.5 overflow-auto p-3 text-xs",
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-700 dark:bg-violet-950/60 dark:text-violet-300">
|
||||
<Waypoints className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground font-medium">资产梳理 Agent</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-foreground truncate font-medium">
|
||||
{viewMode === "network" ? "网络拓扑梳理 Agent" : "资产关系图"}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{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" })}`
|
||||
: "等待读取资产库"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -934,17 +955,28 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
) : (
|
||||
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!panelCollapsed && <div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setPanelCollapsed(true)} title="收起面板">
|
||||
<ChevronDown className="size-3.5" />
|
||||
<span className="hidden sm:inline">收起</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void resetView()} title="重置视图">
|
||||
<RotateCcw className="size-3.5" />
|
||||
<span className="hidden sm:inline">重置视图</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void remapAssets()} disabled={mapping} title="重新梳理资产">
|
||||
<RefreshCw className={cn("size-3.5", (loading || mapping) && "animate-spin")} />
|
||||
<span className="hidden sm:inline">{mapping ? "正在梳理…" : "重新梳理"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{viewMode === "network" && (
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void remapNetwork()} disabled={mapping} title="重新梳理网络拓扑">
|
||||
<RefreshCw className={cn("size-3.5", (loading || mapping) && "animate-spin")} />
|
||||
<span className="hidden sm:inline">{mapping ? "正在梳理网络…" : "梳理网络拓扑"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
{panelCollapsed && <Button variant="ghost" size="sm" className="h-7 gap-1 px-2" onClick={() => setPanelCollapsed(false)} title="展开面板">
|
||||
<ChevronUp className="size-3.5" />
|
||||
<span className="hidden sm:inline">展开</span>
|
||||
</Button>}
|
||||
</div>
|
||||
{!panelCollapsed && <>
|
||||
<div className="bg-muted/60 flex items-center gap-1 rounded-md p-1">
|
||||
<Button
|
||||
variant={viewMode === "asset" ? "secondary" : "ghost"}
|
||||
@@ -1010,9 +1042,10 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
)}
|
||||
<p className="text-muted-foreground/80 border-border/60 border-t pt-2 leading-relaxed">
|
||||
{viewMode === "network"
|
||||
? "网络拓扑只展示资产节点和所在区域;数据库资产使用对应数据库标识。"
|
||||
: "点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。"}
|
||||
? "网络拓扑梳理会话会输出资产关系结果和网络拓扑结果;图中只展示有真实资产证据的节点及区域。"
|
||||
: "资产关系图直接读取资产库,不需要额外梳理;点击节点查看详情并高亮关系链。灰色「⋯」可展开更多资产。"}
|
||||
</p>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{selectedAsset && activePath.length > 0 && data && (
|
||||
|
||||
+4
-4
@@ -451,10 +451,10 @@ export const api = {
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/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<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// ---- 目标管理(总览)----
|
||||
|
||||
Reference in new issue
Block a user