mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-01 18:43:49 +08:00
fix: place topology nodes by zone and show mapping progress
This commit is contained in:
1 parent
6b0acf20f6
commit
d51bf259e4
3 files changed
+86
-16
No files matched your search
@@ -5,7 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// taskAssetMapping starts a visible asset-mapper conversation for the task and
|
||||
@@ -26,7 +27,6 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -59,6 +59,24 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) {
|
||||
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"})
|
||||
title := fmt.Sprintf("资产梳理 · 任务 #%d", taskID)
|
||||
conversation, err := s.m.pg.CreateConversation("asset_mapper", title, nil)
|
||||
if err != nil || conversation == nil {
|
||||
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, "写入资产梳理会话失败")
|
||||
return
|
||||
}
|
||||
busyKey := s.convBusyKey(conversation.ID)
|
||||
s.chatMu.Lock()
|
||||
s.chatBusy[busyKey] = true
|
||||
s.chatMu.Unlock()
|
||||
s.runConversation(conversation, message, busyKey, message)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "accepted", "agent_key": "asset_mapper", "conversation_id": conversation.ID,
|
||||
})
|
||||
}
|
||||
@@ -216,6 +216,8 @@ type G6NodeDatum = {
|
||||
lbl: string;
|
||||
size: number;
|
||||
active: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type G6EdgeDatum = { source: string; target: string; active: boolean };
|
||||
@@ -245,7 +247,25 @@ function graphLabel(n: CoverageGraphNode): string {
|
||||
}
|
||||
|
||||
function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDatum[] {
|
||||
const buckets: Record<Zone, RenderNode[]> = { internet: [], boundary: [], internal: [] };
|
||||
for (const rn of renderNodes) {
|
||||
buckets[rn.fold ? "boundary" : zoneOf(rn.node)].push(rn);
|
||||
}
|
||||
const maxCount = Math.max(buckets.internet.length, buckets.boundary.length, buckets.internal.length, 1);
|
||||
const virtualHeight = Math.max(760, maxCount * 42);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const xByZone: Record<Zone, number> = { internet: 150, boundary: 600, internal: 1050 };
|
||||
for (const zone of Object.keys(buckets) as Zone[]) {
|
||||
const items = buckets[zone];
|
||||
items.forEach((rn, index) => {
|
||||
positions.set(rn.key, {
|
||||
x: xByZone[zone],
|
||||
y: 70 + ((index + 1) * (virtualHeight - 140)) / (items.length + 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
return renderNodes.map((rn) => {
|
||||
const position = positions.get(rn.key) ?? { x: 600, y: virtualHeight / 2 };
|
||||
if (rn.fold) {
|
||||
return {
|
||||
id: rn.key,
|
||||
@@ -256,6 +276,7 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDa
|
||||
lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
|
||||
size: 24,
|
||||
active: activeKeys.has(rn.key),
|
||||
...position,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -267,6 +288,7 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDa
|
||||
lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
|
||||
size: kindMeta[rn.kind].size,
|
||||
active: activeKeys.has(rn.key),
|
||||
...position,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -503,6 +525,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
const [activePath, setActivePath] = React.useState<string[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [mapping, setMapping] = React.useState(false);
|
||||
const [mappingConversationId, setMappingConversationId] = React.useState<number | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = React.useState<Date | null>(null);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -536,12 +559,37 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
const remapAssets = React.useCallback(async () => {
|
||||
setMapping(true);
|
||||
try {
|
||||
await api.startAssetMapping(taskId);
|
||||
} finally {
|
||||
const run = await api.startAssetMapping(taskId);
|
||||
setMappingConversationId(run.conversation_id);
|
||||
} catch {
|
||||
setMapping(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mappingConversationId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const state = await api.conversationMessages(mappingConversationId, 0);
|
||||
if (cancelled) return;
|
||||
if (!state.running) {
|
||||
setMapping(false);
|
||||
setMappingConversationId(null);
|
||||
fetchGraph();
|
||||
}
|
||||
} catch {
|
||||
// Keep the spinner while the conversation endpoint is temporarily unavailable.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [fetchGraph, mappingConversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
@@ -632,13 +680,10 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
// 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,
|
||||
// Positions are assigned by zone in toG6Nodes. Preset layout keeps
|
||||
// internet, boundary and internal assets in their corresponding
|
||||
// columns instead of letting a graph ranker collapse everything.
|
||||
type: "preset",
|
||||
},
|
||||
behaviors: ["drag-element", "drag-canvas", "zoom-canvas"],
|
||||
});
|
||||
@@ -733,7 +778,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground font-medium">资产梳理 Agent</div>
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{loading ? "正在更新资产关系…" : lastSyncedAt ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` : "等待首次梳理"}
|
||||
{mapping
|
||||
? "正在梳理资产关系…"
|
||||
: loading
|
||||
? "正在更新资产关系…"
|
||||
: lastSyncedAt
|
||||
? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`
|
||||
: "等待首次梳理"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,7 +804,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
)}
|
||||
<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">重新梳理</span>
|
||||
<span className="hidden sm:inline">{mapping ? "正在梳理…" : "重新梳理"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
|
||||
+2
-1
@@ -451,7 +451,8 @@ 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 }>(`/tasks/${id}/asset-mapping`, {}),
|
||||
startAssetMapping: (id: string) =>
|
||||
post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/asset-mapping`, {}),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// ---- 目标管理(总览)----
|
||||
|
||||
Reference in new issue
Block a user