From a999ed7cfd7e3f2cc623926b41cd7d099c07dcd2 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:00:45 +0800 Subject: [PATCH 01/17] fix(web): move finding retest below status --- .../(main)/function/findings/detail/page.tsx | 209 +++++++++--------- 1 file changed, 108 insertions(+), 101 deletions(-) diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d4a640a..afeeffe 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -182,7 +182,6 @@ function FindingDetailInner() {

{finding.summary || "(无摘要)"}

- 证据 / PoC @@ -226,112 +225,120 @@ function FindingDetailInner() { {/* 右栏:状态区 */} - - - 状态 - - - {/* 漏洞 ID */} - - - #{finding.id} - - +
+ + + 状态 + + + {/* 漏洞 ID */} + + + #{finding.id} + + - {/* 严重等级 */} - - {finding.inherited ? ( - - ) : ( - - )} - + {/* 严重等级 */} + + {finding.inherited ? ( + + ) : ( + + )} + - {/* 处理状态 */} - - {finding.inherited ? ( - - ) : ( - - )} - + {/* 处理状态 */} + + {finding.inherited ? ( + + ) : ( + + )} + - {/* 漏洞类型 */} - - {finding.vulnclass ? ( - {finding.vulnclass} - ) : ( - — - )} - + {/* 漏洞类型 */} + + {finding.vulnclass ? ( + {finding.vulnclass} + ) : ( + — + )} + - {/* 涉及资产 */} - - {finding.assets && finding.assets.length > 0 ? ( -
- {finding.assets.map((a) => ( - - {a.label} - - ))} -
- ) : ( - — - )} -
+ {/* 涉及资产 */} + + {finding.assets && finding.assets.length > 0 ? ( +
+ {finding.assets.map((a) => ( + + {a.label} + + ))} +
+ ) : ( + — + )} +
- {/* 所属任务 */} - - {finding.task_id ? ( - - {finding.task_description || `#${finding.task_id}`} - - - ) : ( - —(任务已删除) - )} - + {/* 所属任务 */} + + {finding.task_id ? ( + + {finding.task_description || `#${finding.task_id}`} + + + ) : ( + —(任务已删除) + )} + - {/* 发现时间 */} - - {fmtTime(finding.ts)} - -
-
+ {/* 发现时间 */} + + {fmtTime(finding.ts)} + + + + +
From 404c4bb2484268e25f7c75e60aac8ac37ed8c183 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:17:38 +0800 Subject: [PATCH 02/17] feat: add finding side question panel --- .../app/(main)/function/findings/detail/page.tsx | 15 +++++++++++++-- web/src/components/side-question-workspace.tsx | 6 +++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d4a640a..74e3cdf 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -12,6 +12,7 @@ import { CopyButton } from "@/components/copy-button"; import { FindingRetestPanel } from "@/components/finding-retest-panel"; import { FindingTrafficPanel } from "@/components/finding-traffic-panel"; import { Markdown } from "@/components/markdown"; +import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace"; import { StatusBadge } from "@/components/status-badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; @@ -21,6 +22,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@ import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useSideQuestions } from "@/hooks/use-side-questions"; import { api } from "@/lib/api"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; @@ -62,6 +64,7 @@ function FindingDetailInner() { const [finding, setFinding] = React.useState(null); const [loaded, setLoaded] = React.useState(false); const [tab, setTab] = React.useState("overview"); + const side = useSideQuestions(finding?.task_id ? `/api/tasks/${finding.task_id}/chat` : null); const load = React.useCallback(() => { if (!id) { @@ -130,7 +133,13 @@ function FindingDetailInner() { const title = finding.name || finding.vulnclass || "未分类"; return ( - + + {/* Sticky header */}
@@ -157,6 +166,7 @@ function FindingDetailInner() { + {finding.inherited && finding.source_task_id && ( 来源任务 #{finding.source_task_id} · 只读 )} @@ -340,7 +350,8 @@ function FindingDetailInner() {
- + + ); } diff --git a/web/src/components/side-question-workspace.tsx b/web/src/components/side-question-workspace.tsx index 5034c2a..f59e814 100644 --- a/web/src/components/side-question-workspace.tsx +++ b/web/src/components/side-question-workspace.tsx @@ -37,12 +37,12 @@ const preparationLabels = { answering: "正在回答…", }; -export function SideQuestionButton({ side }: { side: SideQuestions }) { +export function SideQuestionButton({ side, label = "旁路提问" }: { side: SideQuestions; label?: string }) { if (!side.enabled) return null; return ( - ); } From 76c76eefa9d944d08fc8cfdce2a9e687d27d7efb Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:37:17 +0800 Subject: [PATCH 03/17] feat(repeater): redesign request replay workspace --- web/src/app/(main)/_components/live-clock.tsx | 36 ++++ .../app/(main)/_components/main-content.tsx | 4 +- web/src/components/http-code-block.tsx | 91 +++++++--- .../components/repeater/repeater-editor.tsx | 91 ++++++++-- .../repeater/repeater-navigator.tsx | 7 +- .../repeater/repeater-text-pane.tsx | 68 ++++++- .../repeater/repeater-workspace.tsx | 169 +++++++++++++----- 7 files changed, 377 insertions(+), 89 deletions(-) create mode 100644 web/src/app/(main)/_components/live-clock.tsx diff --git a/web/src/app/(main)/_components/live-clock.tsx b/web/src/app/(main)/_components/live-clock.tsx new file mode 100644 index 0000000..ed19102 --- /dev/null +++ b/web/src/app/(main)/_components/live-clock.tsx @@ -0,0 +1,36 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function formatClock(value: Date) { + const pad = (part: number) => String(part).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; +} + +export function LiveClock({ className }: { className?: string }) { + const [now, setNow] = React.useState(null); + + React.useEffect(() => { + const update = () => setNow(new Date()); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, []); + + return ( + + ); +} diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index 44df7c1..d6951cf 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -10,12 +10,13 @@ import { useCurrentUser } from "@/hooks/use-current-user"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; +import { LiveClock } from "./live-clock"; import { AccountSwitcher } from "./sidebar/account-switcher"; import { LayoutControls } from "./sidebar/layout-controls"; import { SearchDialog } from "./sidebar/search-dialog"; import { ThemeSwitcher } from "./sidebar/theme-switcher"; import { UpdateBadge } from "./update-badge"; -import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; // 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。 function isFullBleed(pathname: string) { @@ -77,6 +78,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
+ {version && ( 版本 · {version} )} diff --git a/web/src/components/http-code-block.tsx b/web/src/components/http-code-block.tsx index 0a24de1..9808551 100644 --- a/web/src/components/http-code-block.tsx +++ b/web/src/components/http-code-block.tsx @@ -111,6 +111,70 @@ function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) return body; } +/** + * Render a packet with the same syntax rules everywhere in the UI. + * + * `lineNumbers` is disabled by the repeater editor because it already owns a + * synchronised gutter. Keeping the tokenisation here prevents packet previews + * and editable packet panes from drifting apart visually. + */ +export function HttpPacketSyntax({ + raw, + wrapLines = true, + lineNumbers = true, + emptyFallback = "(空)", + lineClassName = "leading-relaxed", +}: { + raw: string; + wrapLines?: boolean; + lineNumbers?: boolean; + emptyFallback?: string; + lineClassName?: string; +}) { + const value = raw || emptyFallback; + const lines = value.replaceAll("\r\n", "\n").split("\n"); + const separator = lines.indexOf(""); + // LLM records and a few provider diagnostics contain a standalone JSON + // payload instead of an HTTP head. Treat that as a body too, so those + // packet views use the same token colours as captured HTTP traffic. + const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value; + const bodyFormat = detectBodyFormat(body); + const renderLine = (line: string, index: number) => { + if (separator < 0 && bodyFormat !== "plain") return ; + if (index === 0) return ; + if (separator < 0 || index < separator) return ; + if (index === separator) return null; + return ; + }; + + return ( + <> + {lines.map((line, index) => ( +
+ {lineNumbers ? ( + + {index + 1} + + ) : null} + + {renderLine(line, index)} + +
+ ))} + + ); +} + // Empty grid rows have no text, so native selection copying drops their newlines. // Map the selected DOM boundaries back to the raw packet instead. function selectedPacketText(container: HTMLElement, value: string, lines: string[]): string | null { @@ -150,9 +214,6 @@ export function HttpCodeBlock({ raw }: { raw: string }) { const [copied, setCopied] = React.useState(false); const value = raw || "(空)"; const lines = value.replaceAll("\r\n", "\n").split("\n"); - const separator = lines.indexOf(""); - const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; - const bodyFormat = detectBodyFormat(body); React.useEffect(() => { if (!copied) return; @@ -169,13 +230,6 @@ export function HttpCodeBlock({ raw }: { raw: string }) { toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); }; - const renderLine = (line: string, index: number) => { - if (index === 0) return ; - if (separator < 0 || index < separator) return ; - if (index === separator) return null; - return ; - }; - return (
@@ -234,22 +288,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) { selection.addRange(range); }} > - {lines.map((line, index) => ( -
- - {renderLine(line, index)} - -
- ))} +
); diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx index 7f1fc9c..82aa831 100644 --- a/web/src/components/repeater/repeater-editor.tsx +++ b/web/src/components/repeater/repeater-editor.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import { SendIcon } from "lucide-react"; +import { SendIcon, Settings2 } from "lucide-react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -32,14 +32,40 @@ import { newId, type RepeaterTab } from "./repeater-state"; import { useRepeaterStore } from "./repeater-store"; import { RepeaterTextPane } from "./repeater-text-pane"; +function prettyResponse(response: NonNullable) { + const raw = buildRawResponse(response); + const separator = raw.indexOf("\n\n"); + if (separator < 0) return raw; + const body = raw.slice(separator + 2); + try { + return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`; + } catch { + return raw; + } +} + +function responseStatusClass(status: number) { + if (status >= 500) return "border-rose-300 text-rose-600"; + if (status >= 400) return "border-amber-300 text-amber-600"; + if (status >= 300) return "border-sky-300 text-sky-600"; + return "border-emerald-300 text-emerald-600"; +} + +function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") { + if (!response) return ""; + return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response); +} + export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { const { useProxy, insecure, response } = tab; const updateTab = useRepeaterStore((state) => state.updateTab); const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined); const [targetOpen, setTargetOpen] = React.useState(false); const [targetDraft, setTargetDraft] = React.useState(""); + const [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty"); const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : ""); const requestText = buildRawRequest(tab); + const responseText = responseTextFor(response, responseMode); function changeRequest(rawRequest: string) { try { const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin); @@ -96,11 +122,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { } return ( -
-
- - 发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} - +
+
+ 目标 + + {targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} + -
-
+
发送选项 {response ? ( -
- {response.status ? {response.status_text || response.status} : null} +
+ {response.status ? ( + + {response.status} {response.status_text} + + ) : null} {response.elapsed_ms} ms {response.body_bytes.toLocaleString()} 字节 {response.body_truncated ? 正文已截断 : null}
) : null}
-
+
Raw + } />
{response?.error ? ( @@ -164,8 +205,32 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { + + +
+ } />
diff --git a/web/src/components/repeater/repeater-navigator.tsx b/web/src/components/repeater/repeater-navigator.tsx index 17047ce..dee52f3 100644 --- a/web/src/components/repeater/repeater-navigator.tsx +++ b/web/src/components/repeater/repeater-navigator.tsx @@ -32,9 +32,11 @@ type Rename = { kind: "tab" | "group"; id: string; name: string }; export function RepeaterNavigator({ workspace, onRename, + className, }: { workspace: RepeaterWorkspace; onRename: (value: Rename) => void; + className?: string; }) { const store = useRepeaterStore(); const [filters, setFilters] = React.useState(DEFAULT_FILTERS); @@ -77,7 +79,10 @@ export function RepeaterNavigator({ return (
-
+
); diff --git a/web/src/components/traffic-evidence-viewer.tsx b/web/src/components/traffic-evidence-viewer.tsx index f614f27..812c473 100644 --- a/web/src/components/traffic-evidence-viewer.tsx +++ b/web/src/components/traffic-evidence-viewer.tsx @@ -13,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/lib/api"; import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types"; @@ -114,17 +113,16 @@ export function TrafficEvidenceViewer({ {error} ) : detail ? ( - - - 请求 Request - 响应 Response - +
{(["request", "response"] as const).map((side) => ( - +
- - 正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""} - +
+

{side === "request" ? "请求 Request" : "响应 Response"}

+ + 正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""} + +
) : null} - +
))} - +
) : ( )} @@ -193,18 +191,16 @@ export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onCl {error} ) : detail ? ( - - - 请求 Request - 响应 Response - - +
+
+

请求 Request

- - +
+
+

响应 Response

- - +
+
) : ( )} From 44924ec30b49a554a755b57f996d85f1232f238c Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:58 +0800 Subject: [PATCH 05/17] feat(web): add segmented asset topology paths --- agent/promptcatalog.go | 23 ++- agent/toolcatalog.go | 4 +- db/db.go | 2 + .../tasks/detail/_tabs/coverage-graph-tab.tsx | 160 ++++++++++++++++-- 4 files changed, 166 insertions(+), 23 deletions(-) diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 67f1fa7..783dc20 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -24,6 +24,16 @@ const autoDefaultTmpl = `你是 **Auto**,这个渗透测试平台的「操作 - 用人话简洁汇报你做了什么、结果如何;只根据工具真实返回作答,不臆造。 - 只在授权范围内操作。` +// assetMapperDefaultTmpl is the read-only assistant behind the topology canvas. +const assetMapperDefaultTmpl = `你是授权渗透测试平台的「资产梳理」Agent。你的职责是根据资产库中的真实数据,整理公网、边界服务和内网业务资产之间的关系,并解释用户指定的入站路径。 + +工作规则: +- 先使用 list_assets 和 list_companies 获取事实,再给出结论;没有证据的关系标记为“待验证”,不要臆造。 +- 识别域名、子域名、IP、服务、应用和端点之间的关联,说明数据来源和最近更新时间。 +- 用户询问“从哪里进入内网”时,按“公网入口 → 边界服务 → 目标资产”的顺序回答;当前资产库没有网络流量或防火墙证据时,明确说明这是资产关系推导,不把它描述成已验证的网络通路。 +- 用简洁中文输出,优先给出资产清单、关系链、可信度和下一步验证建议。 +- 只读分析,不主动修改资产库,不执行探测。` + // pentestDefaultTmpl is the built-in "渗透测试" (solo pentest) agent's prompt. Unlike // the orchestration roles (goals/planner/worker), it runs standalone via the chat page // and is its own planner + executor + auditor. Default tools: list_assets / insert_assets @@ -112,11 +122,12 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏 // source while the same string stays as the in-code render fallback. func BuiltinPromptSeeds() map[string]string { return map[string]string{ - "goals": goalsDefaultTmpl, - "planner": plannerDefaultTmpl, - "mainagent": mainAgentDefaultTmpl, - "worker": workerDefaultTmpl, - "auto": autoDefaultTmpl, - "pentest": pentestDefaultTmpl, + "goals": goalsDefaultTmpl, + "planner": plannerDefaultTmpl, + "mainagent": mainAgentDefaultTmpl, + "worker": workerDefaultTmpl, + "auto": autoDefaultTmpl, + "asset_mapper": assetMapperDefaultTmpl, + "pentest": pentestDefaultTmpl, } } diff --git a/agent/toolcatalog.go b/agent/toolcatalog.go index 77b7cb2..32b732e 100644 --- a/agent/toolcatalog.go +++ b/agent/toolcatalog.go @@ -43,6 +43,8 @@ func builtinToolsByAgent() map[string][]actool.CoreTool { // auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。 // 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。 "auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()}, + // asset_mapper 只读资产库,供资产梳理和拓扑解释使用。 + "asset_mapper": {ts.listAssets(), ts.listCompanies()}, // pentest(独立渗透 agent)默认绑:查资产 / 插资产 / 报漏洞 / 查漏洞 / 查企业。 // 新库由此 seed 写入;老库由 seedPentestDefaultBindings 迁移。 "pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies()}, @@ -62,7 +64,7 @@ var defaultUnbound = map[string]bool{"goal_met": true} // 多个 agent 都有)合成一条,Agents 取并集;defaultUnbound 里的工具则强制绑定为空。 func BuiltinToolSeeds() []ToolSeed { byAgent := builtinToolsByAgent() - order := []string{"mainagent", "goals", "planner", "worker", "auto", "pentest"} + order := []string{"mainagent", "goals", "planner", "worker", "auto", "asset_mapper", "pentest"} type acc struct { tool actool.CoreTool diff --git a/db/db.go b/db/db.go index 90f9fcb..e468045 100644 --- a/db/db.go +++ b/db/db.go @@ -209,6 +209,8 @@ var builtinAgents = []builtinAgent{ }, false, nil}, // Auto:内置「平台操作」agent。不参与渗透编排循环,经对话页驱动,用工具操作平台。 {"auto", "Auto", "assistant", "平台操作助手:用工具管理任务(建/看/暂停/给提示)与资产,并可创建/修改 skill、自定义工具、MCP。", nil, false, nil}, + // Asset mapper: read-only assistant used by the topology canvas and asset views. + {"asset_mapper", "资产梳理", "assistant", "资产梳理助手:汇总公网、边界与内网资产关系,基于真实资产数据解释入站路径,不凭空推断连接。", nil, false, nil}, // 渗透测试:内置「独立渗透」agent。经对话页驱动,一人从侦察到收尾走完整条渗透链,自己规划自己执行自己验证。默认开启交互式 shell。 {"pentest", "渗透测试", "assistant", "独立渗透 agent:一人从侦察→找攻击面→深入利用→验证→收尾走完整条链,自己规划、自己执行、自己对抗式验证。", nil, true, intp(0)}, } 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 d16d2e5..ec35766 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 @@ -34,6 +34,34 @@ const FOLD_STEP = 20; type Kind = CoverageGraphNode["kind"]; +type Zone = "internet" | "boundary" | "internal"; + +const zoneMeta: Record = { + internet: { + label: "互联网区", + description: "公网域名 / 外部入口", + className: "bg-sky-50/70 dark:bg-sky-950/20", + }, + boundary: { + label: "边界区", + description: "服务 / 网关 / 暴露面", + className: "bg-amber-50/70 dark:bg-amber-950/15", + }, + internal: { + label: "内网区", + description: "业务资产 / 应用 / 端点", + className: "bg-emerald-50/65 dark:bg-emerald-950/15", + }, +}; + +// 当前后端资产关系以域名、服务和业务资产为主,先用类型推导可视化分区。 +// 后续接入 VPC/网段/防火墙数据后,可以直接由 API 返回 zone 覆盖这里的推导结果。 +function zoneOf(node: CoverageGraphNode): Zone { + if (node.kind === "root_domain" || node.kind === "subdomain") return "internet"; + if (node.kind === "service") return "boundary"; + return "internal"; +} + type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number }; const kindMeta: Record = { @@ -185,8 +213,11 @@ type G6NodeDatum = { inScope: boolean; lbl: string; size: number; + active: boolean; }; +type G6EdgeDatum = { source: string; target: string; active: boolean }; + function trunc(s: string, n = 26): string { return s.length > n ? `${s.slice(0, n - 1)}…` : s; } @@ -211,7 +242,7 @@ function graphLabel(n: CoverageGraphNode): string { return n.label; } -function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { +function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDatum[] { return renderNodes.map((rn) => { if (rn.fold) { return { @@ -222,6 +253,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { inScope: false, lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`, size: 24, + active: activeKeys.has(rn.key), }; } return { @@ -232,6 +264,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { inScope: rn.node.in_scope, lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label), size: kindMeta[rn.kind].size, + active: activeKeys.has(rn.key), }; }); } @@ -248,6 +281,7 @@ function nodeFill(d: G6NodeDatum): string { return "#94a3b8"; } function nodeStroke(d: G6NodeDatum): string { + if (d.active) return "#0f766e"; if (d.fold || !d.inScope) return "#94a3b8"; if (d.tested) return "#0f766e"; return "#64748b"; @@ -464,16 +498,19 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const [expanded, setExpanded] = React.useState>(new Map()); const [selectedAsset, setSelectedAsset] = React.useState(null); const [selectedFoldId, setSelectedFoldId] = React.useState(null); + const [activePath, setActivePath] = React.useState([]); const [loading, setLoading] = React.useState(true); + const [lastSyncedAt, setLastSyncedAt] = React.useState(null); const containerRef = React.useRef(null); const graphRef = React.useRef(null); // click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。 const renderMapRef = React.useRef>(new Map()); - const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: { source: string; target: string }[] }>({ + const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: G6EdgeDatum[] }>({ nodes: [], edges: [], }); + const graphDataRef = React.useRef(null); const fetchGraph = React.useCallback(() => { setLoading(true); @@ -484,6 +521,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // tested 状态——不显示测试进度、不做已测高亮。 const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false })); setData({ nodes, edges: g.edges ?? [] }); + graphDataRef.current = { nodes, edges: g.edges ?? [] }; + setLastSyncedAt(new Date()); }) .catch(() => { /* 保留上一次数据 */ @@ -506,14 +545,18 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。 const sig = React.useMemo( () => - `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}`, - [renderNodes, renderEdges], + `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}`, + [activePath, renderNodes, renderEdges], ); // 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。 gDataRef.current = { - nodes: toG6Nodes(renderNodes), - edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })), + nodes: toG6Nodes(renderNodes, new Set(activePath)), + edges: renderEdges.map((e) => ({ + source: e.src, + target: e.dst, + active: activePath.includes(e.src) && activePath.includes(e.dst), + })), }; const rmap = new Map(); for (const rn of renderNodes) rmap.set(rn.key, rn); @@ -542,7 +585,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera container: containerRef.current, autoResize: true, autoFit: "view", - background: "#f0f2f7", + background: "transparent", node: { style: { size: (d: unknown) => nd(d).size, @@ -561,10 +604,18 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera labelBackgroundFill: "rgba(255,255,255,0.75)", labelBackgroundRadius: 3, labelPadding: [1, 3], + opacity: (d: unknown) => + gDataRef.current.nodes.some((node) => node.active) && !nd(d).active ? 0.28 : 1, }, }, edge: { - style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false }, + style: { + stroke: (d: unknown) => ((d as G6EdgeDatum).active ? "#0f766e" : "#cbd5e1"), + lineWidth: (d: unknown) => ((d as G6EdgeDatum).active ? 2.5 : 1), + opacity: (d: unknown) => + gDataRef.current.edges.some((edge) => edge.active) && !(d as G6EdgeDatum).active ? 0.18 : 1, + endArrow: true, + }, }, layout: { type: "d3-force", @@ -590,8 +641,22 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera if (!id) return; const rn = renderMapRef.current.get(id); if (!rn) return; - if (rn.fold) setSelectedFoldId(rn.key); - else setSelectedAsset(rn.node); + if (rn.fold) { + setSelectedFoldId(rn.key); + return; + } + setSelectedAsset(rn.node); + const parents = new Map(); + for (const edge of graphDataRef.current?.edges ?? []) { + if (!parents.has(edge.src)) parents.set(edge.src, edge.dst); + } + const lineage: string[] = []; + let cursor: string | undefined = rn.node.key; + while (cursor && !lineage.includes(cursor)) { + lineage.push(cursor); + cursor = parents.get(cursor); + } + setActivePath(lineage); }); graphRef.current = graph; applyData(); @@ -636,14 +701,38 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0; return ( -
-
+
+
+ {(Object.keys(zoneMeta) as Zone[]).map((zone, index) => ( +
+
+
{zoneMeta[zone].label}
+
{zoneMeta[zone].description}
+
+
+ ))} +
+
{/* 图例 + 统计 + 刷新(叠加层) */} -
+
+
+ + + +
+
资产梳理 Agent
+
+ {loading ? "正在更新资产关系…" : lastSyncedAt ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` : "等待首次梳理"} +
+
+
{total > 0 ? ( - + 范围内 {inScope} {coverageEnabled && ( <> @@ -656,8 +745,9 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera ) : ( {loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"} )} -
@@ -689,11 +779,49 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera 范围外
+
+ {(Object.keys(zoneMeta) as Zone[]).map((zone) => ( + + + {zoneMeta[zone].label} + + ))} +

- 力导向布局,可拖拽节点、滚轮缩放;灰色「⋯」是折叠节点,点开可展开更多。 + 点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。

+ {selectedAsset && activePath.length > 0 && data && ( +
+
+
+
关系路径
+
从当前资产向上追溯关联关系
+
+ +
+
+ {[...activePath].reverse().map((key, index, path) => { + const node = data.nodes.find((item) => item.key === key); + if (!node) return null; + return ( + +
+ + {zoneMeta[zoneOf(node)].label} + {node.label} +
+ {index < path.length - 1 &&
↓
} +
+ ); + })} +
+
+ )} + !o && setSelectedAsset(null)} /> Date: Wed, 28 Oct 2026 18:08:12 +0800 Subject: [PATCH 06/17] feat: add structured finding reproduction screenshots --- agent/promptcatalog.go | 9 + server/assembly.go | 12 + server/manager.go | 9 +- server/server.go | 81 +++-- server/workspace.go | 32 ++ .../findings/_components/findings-table.tsx | 10 +- .../(main)/function/findings/detail/page.tsx | 108 ++++--- web/src/app/(main)/system/settings/page.tsx | 110 ++++--- .../components/finding-reproduction-panel.tsx | 279 ++++++++++++++++++ web/src/components/markdown.tsx | 19 +- web/src/lib/types.ts | 4 +- 11 files changed, 564 insertions(+), 109 deletions(-) create mode 100644 web/src/components/finding-reproduction-panel.tsx diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 67f1fa7..b46a925 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -90,11 +90,20 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏 4. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。 5. **保存**:调用 **update_finding_report(finding_id=, report=, evidence_version=<实际读取的 version>)** 保存;未读取版本时省略 evidence_version,不得猜测。这是你的最终产物——不写进去等于没做。 +━━ 报告步骤截图(仅在系统设置开启时)━━ +- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。 +- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding--step-.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:![步骤 截图](workspace://<截图绝对路径>)。 +- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。 + ━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━ - ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。 - ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。 - ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。 - ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。 +- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。 +- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。 +- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。 +- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。 - ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。 - ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。 - ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。 diff --git a/server/assembly.go b/server/assembly.go index 4ff8efe..32bf3d5 100644 --- a/server/assembly.go +++ b/server/assembly.go @@ -96,6 +96,13 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor continue } for _, t := range ts { + // Playwright exposes the screenshot capability as a separate tool. Keep + // it opt-in so a normal browser MCP session does not unexpectedly send + // image payloads to the model or consume extra tokens. + if m.Name == browserMCPName && isBrowserScreenshotTool(t.Name()) && + (agentKey != "reporter" || !pg.GetBool(settingBrowserScreenshot, false)) { + continue + } extra = append(extra, t) allNames = append(allNames, t.Name()) serverTools[m.Name] = append(serverTools[m.Name], t.Name()) @@ -180,6 +187,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor } } +func isBrowserScreenshotTool(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + return strings.Contains(name, "screenshot") || strings.Contains(name, "take_screenshot") +} + func idSet(ids []int64) map[int64]bool { m := make(map[int64]bool, len(ids)) for _, id := range ids { diff --git a/server/manager.go b/server/manager.go index bd7b1f3..894f7ac 100644 --- a/server/manager.go +++ b/server/manager.go @@ -264,6 +264,7 @@ type Manager struct { // Settings keys the UI toggles at runtime. const ( settingTrafficCapture = "traffic_capture" + settingBrowserScreenshot = "browser_screenshot_enabled" settingAgentTrafficBinding = "agent_traffic_binding" settingWebSearchOn = "web_search_enabled" settingWebSearchBackend = "web_search_backend" @@ -273,11 +274,11 @@ const ( // settingGlobalProxy is the global egress proxy for all target traffic // (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which // only routes the search backend) and the per-profile LLM proxy. - settingGlobalProxy = "global_proxy" + settingGlobalProxy = "global_proxy" settingShowEgressIP = "show_egress_ip" - settingWorkers = "workers" - settingWorkerPools = "worker_pools" - settingLLMRecord = "llm_record" + settingWorkers = "workers" + settingWorkerPools = "worker_pools" + settingLLMRecord = "llm_record" // LLM 轮询(故障转移)。默认关闭——开启后走「全局激活配置」的 agent 在当前配置 // 不可用(余额不足/key 失效/限流/服务异常)时自动切到下一个配置。 // settingLLMPoolBindFallback 仅在轮询开启时有意义:默认关闭,即 agent/任务显式 diff --git a/server/server.go b/server/server.go index 6ee0cf5..f43b3ba 100644 --- a/server/server.go +++ b/server/server.go @@ -199,6 +199,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi if err != nil || t == "" { return "", false } + if key == "reporter" && m.pg.GetBool(settingBrowserScreenshot, false) { + t += ` + +【报告步骤截图已开启】 + 报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding--step-.png;确认文件存在后,在对应步骤中加入 Markdown 图片:![步骤 截图](workspace://<截图绝对路径>)。截图失败时如实写明,不要用占位图或把截图描述冒充图片。` + } return t, true } // Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default. @@ -772,6 +778,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir) mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete) mux.HandleFunc("GET /api/workspace/download", s.wsDownload) + mux.HandleFunc("GET /api/workspace/preview", s.wsPreview) mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd) @@ -3442,26 +3449,27 @@ func (s *Server) settingsPayload() map[string]any { concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI } return map[string]any{ - "traffic_capture": s.m.TrafficEnabled(), - "agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false), - "task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false), - db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false), - db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false), - db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false), - db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false), - "llm_record": s.m.LLMRecordEnabled(), - "web_search_enabled": on, - "web_search_backend": backend, - "brave_key_set": strings.TrimSpace(braveKey) != "", - "tavily_key_set": strings.TrimSpace(tavilyKey) != "", - "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 - "global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 - "show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false), - "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) - "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 - "worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话 - "task_concurrency_enabled": concOn, // 任务并发上限开关(默认关) - "task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5) + "traffic_capture": s.m.TrafficEnabled(), + "browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false), + "agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false), + "task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false), + db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false), + db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false), + db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false), + db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false), + "llm_record": s.m.LLMRecordEnabled(), + "web_search_enabled": on, + "web_search_backend": backend, + "brave_key_set": strings.TrimSpace(braveKey) != "", + "tavily_key_set": strings.TrimSpace(tavilyKey) != "", + "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 + "global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 + "show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false), + "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) + "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 + "worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话 + "task_concurrency_enabled": concOn, // 任务并发上限开关(默认关) + "task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5) // LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时 // 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。 "llm_pool_enabled": s.m.LLMPoolEnabled(), @@ -3520,6 +3528,7 @@ func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) { func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { var req struct { TrafficCapture *bool `json:"traffic_capture"` + BrowserScreenshot *bool `json:"browser_screenshot_enabled"` AgentTrafficBinding *bool `json:"agent_traffic_binding"` TaskIntelligence *bool `json:"task_intelligence_enabled"` TaskAutoTitle *bool `json:"task_auto_title_enabled"` @@ -3715,6 +3724,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { return } } + if req.BrowserScreenshot != nil { + if err := s.setReporterBrowserScreenshotVisibility(*req.BrowserScreenshot); err != nil { + writeErr(w, 500, err.Error()) + return + } + if err := s.m.pg.SetBool(settingBrowserScreenshot, *req.BrowserScreenshot); err != nil { + writeErr(w, 500, err.Error()) + return + } + } if req.TrafficCapture != nil { if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil { writeErr(w, 500, err.Error()) @@ -3766,6 +3785,28 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.settingsPayload()) } +// setReporterBrowserScreenshotVisibility keeps the report writer's browser MCP +// permission in sync with the screenshot setting. Other agents retain their +// explicit MCP permissions. +func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error { + reporter, err := s.m.pg.GetAgentByKey("reporter") + if err != nil || reporter == nil { + return err + } + servers, err := s.m.pg.ListMCP() + if err != nil { + return err + } + for _, server := range servers { + if server.Name == browserMCPName { + return s.m.pg.ToggleVisibility(reporter.ID, "mcp", server.ID, enabled) + } + } + // The built-in browser MCP may be deleted by an administrator. Keep the + // setting writable so it takes effect automatically if the server returns. + return nil +} + // testWebSearch runs a real "test" search with the given (or currently saved) // backend/proxy/key to verify the config can actually reach a search backend — // mirroring testLLM. Backend/proxy come from the request (so the form's unsaved diff --git a/server/workspace.go b/server/workspace.go index a5d8a66..e6b7918 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -225,6 +225,38 @@ func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, abs) } +// GET /api/workspace/preview?path= — serve an image inline for report +// Markdown. Unlike download, this uses an inline content disposition so a +// report can render a saved browser screenshot with a normal Markdown image URL. +func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) { + rawPath := strings.TrimSpace(r.URL.Query().Get("path")) + abs, ok := s.wsResolve(rawPath) + if filepath.IsAbs(rawPath) { + base := filepath.Clean(s.m.dir) + candidate := filepath.Clean(rawPath) + ok = candidate == base || strings.HasPrefix(candidate, base+string(os.PathSeparator)) + if ok { + abs = candidate + } + } + if !ok { + writeErr(w, 400, "非法路径") + return + } + fi, err := os.Stat(abs) + if err != nil || fi.IsDir() { + writeErr(w, 404, "文件不存在") + return + } + ext := strings.ToLower(filepath.Ext(abs)) + if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" && ext != ".gif" { + writeErr(w, 415, "仅支持图片预览") + return + } + w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(filepath.Base(abs))+"\"") + http.ServeFile(w, r, abs) +} + // POST /api/workspace/upload?path= — multipart form field "file" (one or more). func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) { dirAbs, ok := s.wsResolve(r.URL.Query().Get("path")) diff --git a/web/src/app/(main)/function/findings/_components/findings-table.tsx b/web/src/app/(main)/function/findings/_components/findings-table.tsx index a4b9cc8..b10ba5d 100644 --- a/web/src/app/(main)/function/findings/_components/findings-table.tsx +++ b/web/src/app/(main)/function/findings/_components/findings-table.tsx @@ -75,6 +75,12 @@ export interface FindingEdit { export type FindingReport = { status: "loading" | "done" | "error"; text: string }; +function isStructuredReproductionReport(report: string) { + return ( + /^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report) + ); +} + // Exploration node ids are only unique inside a task. Prefer the persisted // finding id and otherwise namespace the node id by task so editing one group // cannot update a similarly-named node in another expanded group. @@ -446,8 +452,8 @@ export function FindingsTable({ {f.evidence} - {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} - {f.finding_id && ( + {/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */} + {f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d4a640a..0abb3ee 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -10,6 +10,7 @@ import { toast } from "sonner"; import { CopyButton } from "@/components/copy-button"; import { FindingRetestPanel } from "@/components/finding-retest-panel"; +import { FindingReproductionPanel } from "@/components/finding-reproduction-panel"; import { FindingTrafficPanel } from "@/components/finding-traffic-panel"; import { Markdown } from "@/components/markdown"; import { StatusBadge } from "@/components/status-badge"; @@ -44,6 +45,12 @@ function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN"); } +function usesStructuredReproduction(report: string) { + return ( + /^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report) + ); +} + // FieldRow is one label/value line in the right-hand status panel. function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -128,6 +135,7 @@ function FindingDetailInner() { } const title = finding.name || finding.vulnclass || "未分类"; + const structuredReproduction = usesStructuredReproduction(finding.report || ""); return ( @@ -182,47 +190,65 @@ function FindingDetailInner() {

{finding.summary || "(无摘要)"}

- - - - 证据 / PoC - - - {finding.evidence ? ( -
-                      {finding.evidence}
-                    
- ) : ( -

(无证据)

- )} -
-
- - {/* 证据下方:详细报告(Markdown 渲染) */} - - - 详细报告 - {finding.report && } - - - {finding.report_stale ? ( - - 流量证据已变更,详细报告待更新。 - - ) : null} - {finding.report ? ( - - ) : ( -

暂无详细报告。

- )} -
-
+ {structuredReproduction ? ( + <> + + + + ) : ( + <> + + + 证据 / PoC + + + {finding.evidence ? ( +
+                          {finding.evidence}
+                        
+ ) : ( +

(无证据)

+ )} +
+
+ + + + + 详细报告 + {finding.report && } + + + {finding.report_stale ? ( + + 流量证据已变更,详细报告待更新。 + + ) : null} + {finding.report ? ( + + ) : ( +

暂无详细报告。

+ )} +
+
+ + )}
{/* 右栏:状态区 */} diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index ebb8564..b664ff7 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -2,7 +2,15 @@ import * as React from "react"; -import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react"; +import { + CameraIcon, + CpuIcon, + FlaskConicalIcon, + KeyboardIcon, + RadioTowerIcon, + SearchIcon, + ShieldAlertIcon, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -29,6 +37,7 @@ export default function SystemSettingsPage() { const [chatAutoTitleEnabled, setChatAutoTitleEnabled] = React.useState(false); const [savingIntelligence, setSavingIntelligence] = React.useState(false); const [trafficCapture, setTrafficCapture] = React.useState(false); + const [browserScreenshot, setBrowserScreenshot] = React.useState(false); const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false); const [webSearch, setWebSearch] = React.useState(false); const [backend, setBackend] = React.useState("ddgs"); @@ -86,6 +95,7 @@ export default function SystemSettingsPage() { setTaskAutoTitleEnabled(!!s.task_auto_title_enabled); setChatAutoTitleEnabled(!!s.chat_auto_title_enabled); setTrafficCapture(!!s.traffic_capture); + setBrowserScreenshot(!!s.browser_screenshot_enabled); setAgentTrafficBinding(!!s.agent_traffic_binding); setWebSearch(!!s.web_search_enabled); setBackend(s.web_search_backend || "ddgs"); @@ -213,6 +223,22 @@ export default function SystemSettingsPage() { .finally(() => setSaving(false)); }; + const toggleBrowserScreenshot = (v: boolean) => { + setBrowserScreenshot(v); + setSaving(true); + api + .setSettings({ browser_screenshot_enabled: v }) + .then((s) => { + apply(s); + toast.success(v ? "已开启浏览器截图工具" : "已关闭浏览器截图工具"); + }) + .catch((e) => { + setBrowserScreenshot(!v); + toast.error(`保存失败:${(e as Error).message}`); + }) + .finally(() => setSaving(false)); + }; + const toggleInjectPlanner = (v: boolean) => { setInjectPlanner(v); // optimistic api @@ -262,9 +288,7 @@ export default function SystemSettingsPage() { const toggleAutoTitle = (key: "task_auto_title_enabled" | "chat_auto_title_enabled", value: boolean) => { const setter = key === "task_auto_title_enabled" ? setTaskAutoTitleEnabled : setChatAutoTitleEnabled; const patch: Partial = - key === "task_auto_title_enabled" - ? { task_auto_title_enabled: value } - : { chat_auto_title_enabled: value }; + key === "task_auto_title_enabled" ? { task_auto_title_enabled: value } : { chat_auto_title_enabled: value }; setter(value); api .setSettings(patch) @@ -497,6 +521,31 @@ export default function SystemSettingsPage() { + + + + + 浏览器截图 + + + 开启后,报告撰写 Agent 会自动获得 browser + MCP,并在复现步骤对应的页面上保存截图,报告中直接显示对应步骤截图。 截图会增加 Token + 消耗;关闭后不会自动给报告撰写 Agent 授权截图能力,已有历史报告保留原截图。 + + + + + + + + @@ -510,9 +559,8 @@ export default function SystemSettingsPage() { 开启流量捕获时,它作为记录代理的上游(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入 Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
- 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_PROXY 的支持——curl - 可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM - 亲自拨号,工具无感知、稳定生效。 + 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_PROXY 的支持——curl 可用,python + urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
@@ -537,12 +585,7 @@ export default function SystemSettingsPage() { 保存 {/* 测表单里当前的值,不要求先保存。 */} -
@@ -567,15 +610,13 @@ export default function SystemSettingsPage() {
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}

- 点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换{" "} - ifconfig.me),它返回"这个请求从哪个 IP 来"。 - ① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。 + 点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换 ifconfig.me + ),它返回"这个请求从哪个 IP 来"。 ① 是不走代理时的 IP,② 是经全局代理出网时的 + IP。改完不用先保存,直接测。

{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
- - 自定义 IP 探测服务(可选) - + 自定义 IP 探测服务(可选)
- 自定义 IP 探测服务需返回纯文本 IP 或{" "} - {'{"ip":"1.2.3.4"}'} 格式的 JSON。 - 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, - 则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。 + 自定义 IP 探测服务需返回纯文本 IP 或 {'{"ip":"1.2.3.4"}'} 格式的 JSON。 + 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ① 和 ② + 的 IP 不同,就说明流量走了内网代理。
@@ -603,11 +643,7 @@ export default function SystemSettingsPage() {
{c.skipped ? "—" : c.result.ok ? "✓" : "✗"} @@ -629,18 +665,12 @@ export default function SystemSettingsPage() { {c.skip_reason} ) : c.result.ok ? ( - - {c.result.exit_ip} - {" "} - ·{" "} + {c.result.exit_ip} ·{" "} {c.result.latency_ms}ms {c.result.via && c.result.via !== "direct" && !viaCapture && ( - - {" "} - · 经 {c.result.via_label ?? c.result.via} - + · 经 {c.result.via_label ?? c.result.via} )} ) : ( @@ -733,7 +763,9 @@ export default function SystemSettingsPage() { 自动标题 - 为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。 + + 为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。 + @@ -751,7 +783,9 @@ export default function SystemSettingsPage() { 会话标题 - 对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。 + + 对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。 + = 0 ? tail.slice(0, end) : tail).trim(); +} + +function withoutPacket(text: string) { + return text.replace(/```(?:http|bash|shell|sh|json|text)?\s*\n[\s\S]*?```/gi, "").trim(); +} + +function parseSteps(report: string, fallback: string): ReproductionStep[] { + const section = extractSection(report); + if (!section) { + return [ + { + number: 1, + title: "执行验证并确认结果", + body: fallback.trim() || "暂无结构化复现步骤,查看下方原始报告。", + }, + ]; + } + + const markers = [...section.matchAll(/^\s*(?:###\s*)?(?:步骤\s*)?(\d+)[.、::))]\s*(.+)$/gim)]; + if (markers.length === 0) { + return [{ number: 1, title: "复现过程", body: withoutPacket(section) }]; + } + + return markers.map((marker, index) => { + const start = marker.index ?? 0; + const end = markers[index + 1]?.index ?? section.length; + const raw = section.slice(start, end).trim(); + const firstLine = marker[2].trim(); + const rest = raw.slice(marker[0].length).trim(); + return { + number: Number(marker[1]), + title: firstLine, + body: withoutPacket(rest), + }; + }); +} + +function reportSection(report: string, headings: string[]) { + const headingPattern = headings.join("|"); + const match = report.match(new RegExp(`^##\\s+(?:${headingPattern})\\s*$`, "im")); + if (!match || match.index === undefined) return ""; + const tail = report.slice(match.index + match[0].length); + const end = tail.search(/^##\s+/m); + return (end >= 0 ? tail.slice(0, end) : tail).trim(); +} + +function packetText(detail: FindingTrafficDetail, side: "request" | "response") { + const head = side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head; + const body = detail[side].content; + return [head, body].filter(Boolean).join("\n\n").trim(); +} + +function PacketDisclosure({ + findingId, + bindingId, + contextTask, +}: { + findingId: string; + bindingId?: string; + contextTask?: string; +}) { + const [open, setOpen] = React.useState(false); + const [detail, setDetail] = React.useState(null); + const [error, setError] = React.useState(""); + const [repeaterBusy, setRepeaterBusy] = React.useState(false); + const router = useRouter(); + + React.useEffect(() => { + if (!open || !bindingId || detail) return; + let active = true; + setError(""); + api + .findingTrafficDetail(findingId, bindingId, contextTask) + .then((value) => { + if (active) setDetail(value); + }) + .catch((e: Error) => { + if (active) setError(e.message); + }); + return () => { + active = false; + }; + }, [bindingId, contextTask, detail, findingId, open]); + + if (!bindingId) { + return

此步骤暂无已绑定的真实流量证据。

; + } + + async function sendToRepeater() { + if (!detail) return; + setRepeaterBusy(true); + try { + if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载"); + else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载"); + seedFromEvidence( + requestFromEvidence( + { + method: detail.binding.snapshot.method || "GET", + url: detail.binding.snapshot.url || "", + headersText: headersFromReqHead(detail.binding.snapshot.req_head), + body: detail.request.binary ? "" : detail.request.content || "", + }, + detail.binding.snapshot.req_head, + ), + { findingId, bindingId: detail.binding.id }, + ); + router.push("/function/repeater"); + } catch (e) { + toast.error(`送入请求重发失败:${(e as Error).message}`); + } finally { + setRepeaterBusy(false); + } + } + + return ( +
+
+ + {open && detail ? ( + + ) : null} +
+ {open ? ( +
+ {error ?

数据包加载失败:{error}

: null} + {!error && bindingId && !detail ? ( +
+ 正在读取已保存的真实流量… +
+ ) : null} + {detail ? ( +
+
+ {detail.binding.snapshot.method} + {detail.binding.snapshot.status} + {detail.binding.snapshot.url} +
+
+

请求 Request

+ +
+
+

响应 Response

+ +
+
+ ) : null} +
+ ) : null} +
+ ); +} + +export function FindingReproductionPanel({ finding, contextTask }: { finding: Finding; contextTask?: string }) { + const steps = React.useMemo( + () => parseSteps(finding.report ?? "", finding.evidence || finding.summary), + [finding.evidence, finding.report, finding.summary], + ); + const [traffic, setTraffic] = React.useState(undefined); + const overview = reportSection(finding.report ?? "", ["概述", "影响与危害", "受影响范围"]); + const outcome = reportSection(finding.report ?? "", ["最终结果"]); + const rootCause = reportSection(finding.report ?? "", ["根因分析"]); + const remediation = reportSection(finding.report ?? "", ["修复建议"]); + + React.useEffect(() => { + let active = true; + api + .findingTraffic(finding.id, contextTask) + .then((value) => { + if (active) setTraffic(value.bindings); + }) + .catch(() => { + if (active) setTraffic([]); + }); + return () => { + active = false; + }; + }, [contextTask, finding.id]); + + return ( + + +
+
+ 复现步骤 + 步骤同时展示,数据包默认隐藏;需要核对原始请求时再按步骤展开。 +
+ 0 ? "secondary" : "outline"}> + {steps.length} 个步骤 · {traffic?.length ?? finding.traffic_count ?? 0} 个数据包 + +
+
+ + {overview ? ( +
+ +
+ ) : null} + {steps.map((step, index) => { + const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index]; + return ( +
+
+
+
+ + 步骤 {step.number} + +

{step.title}

+
+
+ {step.body ? : null} + +
+
+ ); + })} + {outcome || rootCause || remediation ? ( +
+ {outcome ? : null} + {rootCause ? : null} + {remediation ? : null} +
+ ) : null} +
+
+ ); +} diff --git a/web/src/components/markdown.tsx b/web/src/components/markdown.tsx index 53b98bb..6120ffb 100644 --- a/web/src/components/markdown.tsx +++ b/web/src/components/markdown.tsx @@ -18,6 +18,21 @@ const components: Components = { a: ({ node, ...p }) => ( ), + img: ({ node, src, alt, ...p }) => { + const resolvedSrc = + typeof src === "string" && src.startsWith("workspace://") + ? `/api/workspace/preview?path=${encodeURIComponent(src.slice("workspace://".length))}` + : src; + return ( + {alt + ); + }, hr: () =>
, blockquote: ({ node, ...p }) => (
@@ -42,9 +57,7 @@ const components: Components = { ), - th: ({ node, ...p }) => ( -
- ), + th: ({ node, ...p }) => , td: ({ node, ...p }) => , }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 2ff5cd1..129726a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -970,6 +970,8 @@ export interface TrafficHost { // ---- App settings (runtime toggles) ---- export interface Settings { traffic_capture: boolean; + /** 是否向可见 Agent 暴露浏览器 MCP 的截图工具。默认关闭。 */ + browser_screenshot_enabled?: boolean; credential_agent_enabled: boolean; credential_traffic_binding: boolean; task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示 @@ -991,7 +993,7 @@ export interface Settings { // 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为 // MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。 global_proxy?: string; - show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭 + show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭 python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测) workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效 worker_pools: WorkerPool[]; From fb6942f2b2e7867893a822b853411021eaf11604 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:22:16 +0800 Subject: [PATCH 07/17] fix: align asset coverage zones and remapping chat --- db/task_scope.go | 7 +++- server/server.go | 1 + server/task_asset_mapping.go | 39 +++++++++++++++++++ .../tasks/detail/_tabs/coverage-graph-tab.tsx | 31 +++++++++++++-- web/src/lib/api.ts | 1 + 5 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 server/task_asset_mapping.go 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}`), // ---- 目标管理(总览)---- From 6b0acf20f6ab0571fd6b0f47482cd3968ab6c477 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:50:52 +0800 Subject: [PATCH 08/17] fix: bound asset mapping context and use layered topology --- server/task_asset_mapping.go | 29 +++++++++- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 53 +++++++------------ 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go index 6703d0e..a8a2b95 100644 --- a/server/task_asset_mapping.go +++ b/server/task_asset_mapping.go @@ -30,8 +30,33 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { 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) + type nodeSample struct { + Key, Kind, Label, Domain, IP string + Port int + Tested, InScope bool + } + counts := map[string]int{} + sample := make([]nodeSample, 0, 60) + for _, node := range graph.Nodes { + counts[node.Kind]++ + if len(sample) < 60 { + sample = append(sample, nodeSample{ + Key: node.Key, Kind: node.Kind, Label: node.Label, Domain: node.Domain, + IP: node.IP, Port: node.Port, Tested: node.Tested, InScope: node.InScope, + }) + } + } + edges := graph.Edges + truncatedEdges := len(edges) > 80 + if truncatedEdges { + edges = edges[:80] + } + snapshot, _ := json.Marshal(map[string]any{ + "nodes_total": len(graph.Nodes), "edges_total": len(graph.Edges), + "counts_by_kind": counts, "node_sample": sample, "edge_sample": edges, + "truncated": len(graph.Nodes) > len(sample) || truncatedEdges, + }) + message += "\n\n页面刚读取的任务资产图真实摘要如下;只依据摘要和任务范围解释,避免把全局资产误当成本任务资产。未列出的节点不要逐一补写:\n" + string(snapshot) } } s.StartTriggeredRun("asset_mapper", fmt.Sprintf("资产梳理 · 任务 #%d", taskID), message, taskID, false, description, t.Goal) 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 7540f52..ec12979 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 @@ -29,8 +29,8 @@ import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGr import { cn } from "@/lib/utils"; // 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。 -const FOLD_LIMIT = 20; -const FOLD_STEP = 20; +const FOLD_LIMIT = 12; +const FOLD_STEP = 12; type Kind = CoverageGraphNode["kind"]; @@ -539,9 +539,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera await api.startAssetMapping(taskId); } finally { setMapping(false); - fetchGraph(); } - }, [fetchGraph, taskId]); + }, [taskId]); React.useEffect(() => { fetchGraph(); @@ -566,8 +565,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera gDataRef.current = { nodes: toG6Nodes(renderNodes, new Set(activePath)), edges: renderEdges.map((e) => ({ - source: e.src, - target: e.dst, + // 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. + source: e.dst, + target: e.src, active: activePath.includes(e.src) && activePath.includes(e.dst), })), }; @@ -579,9 +581,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const graph = graphRef.current; if (!graph || graph.destroyed) return; graph.setData(gDataRef.current); - // render() 异步跑 d3-force 布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的 - // context 上访问 transform 抛错(见 runtime/layout transformDataAfterLayout)。这是纯 - // teardown 竞态,吞掉它,不影响功能;真正的渲染错误(图未销毁)仍打日志。 + // render() 异步跑布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的 + // context 上访问 transform 抛错。这是纯 teardown 竞态,吞掉它,不影响功能。 void graph.render().catch((err) => { if (!graph.destroyed) console.error("[coverage-graph] render:", err); }); @@ -631,33 +632,15 @@ 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) => { - // 顶层(公司/根域名)离子节点远一点,叶子近一点。edge.source 可能是 id 或已解析节点。 - const s = (edge as { source: string | { id?: string } }).source; - const srcId = typeof s === "string" ? s : (s?.id ?? ""); - const src = renderMapRef.current.get(srcId); - const k = src && !src.fold ? src.kind : "endpoint"; - return k === "company" || k === "root_domain" ? 120 : 60; - }, - }, - manyBody: { - strength: (d: unknown) => (nd(d).kind === "endpoint" || nd(d).fold ? -60 : -200), - }, + // 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, }, - behaviors: ["drag-element-force", "drag-canvas", "zoom-canvas"], + behaviors: ["drag-element", "drag-canvas", "zoom-canvas"], }); graph.on("node:click", (evt: unknown) => { const id = (evt as { target?: { id?: string } }).target?.id; From d51bf259e46d4afbad1f2ae89d6c6d384bd985c8 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:01:00 +0800 Subject: [PATCH 09/17] fix: place topology nodes by zone and show mapping progress --- server/task_asset_mapping.go | 26 ++++++- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 73 ++++++++++++++++--- web/src/lib/api.ts | 3 +- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go index a8a2b95..1950d8b 100644 --- a/server/task_asset_mapping.go +++ b/server/task_asset_mapping.go @@ -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, + }) } 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 ec12979..3937987 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 @@ -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): G6NodeDatum[] { + const buckets: Record = { 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(); + const xByZone: Record = { 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): 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): 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([]); const [loading, setLoading] = React.useState(true); const [mapping, setMapping] = React.useState(false); + const [mappingConversationId, setMappingConversationId] = React.useState(null); const [lastSyncedAt, setLastSyncedAt] = React.useState(null); const containerRef = React.useRef(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
资产梳理 Agent
- {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" })}` + : "等待首次梳理"}
@@ -753,7 +804,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera )}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2fc4aeb..7b70e24 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -451,7 +451,8 @@ 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`, {}), + startAssetMapping: (id: string) => + post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/asset-mapping`, {}), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), // ---- 目标管理(总览)---- From 495ab57ba7aa4ce55cb0986954a4e77f7d082a1e Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:04:46 +0800 Subject: [PATCH 10/17] fix: focus selected topology paths --- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 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 3937987..d88cbec 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 @@ -667,7 +667,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera labelBackgroundRadius: 3, labelPadding: [1, 3], opacity: (d: unknown) => - gDataRef.current.nodes.some((node) => node.active) && !nd(d).active ? 0.28 : 1, + gDataRef.current.nodes.some((node) => node.active) && !nd(d).active ? 0.5 : 1, }, }, edge: { @@ -708,6 +708,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera cursor = parents.get(cursor); } setActivePath(lineage); + void graph?.focusElement(lineage); }); graphRef.current = graph; applyData(); @@ -856,7 +857,15 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
关系路径
从当前资产向上追溯关联关系
-
From 14ebf4436214ac512b88952af6aac4b3804e902b Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:10:43 +0800 Subject: [PATCH 11/17] fix: restore topology view and mapping progress state --- server/server.go | 1 + server/task_asset_mapping.go | 26 +++++++++++++++ .../tasks/detail/_tabs/coverage-graph-tab.tsx | 33 +++++++++++++++++-- web/src/lib/api.ts | 2 ++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/server/server.go b/server/server.go index e9e2555..788607a 100644 --- a/server/server.go +++ b/server/server.go @@ -767,6 +767,7 @@ func (s *Server) Handler() http.Handler { 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-mapping/status", s.taskAssetMappingStatus) 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 index 1950d8b..79b77d3 100644 --- a/server/task_asset_mapping.go +++ b/server/task_asset_mapping.go @@ -80,3 +80,29 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { "status": "accepted", "agent_key": "asset_mapper", "conversation_id": conversation.ID, }) } + +func (s *Server) taskAssetMappingStatus(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, "task not found") + return + } + conversations, err := s.m.pg.ListConversations() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + title := fmt.Sprintf("资产梳理 · 任务 #%s", t.ID) + for _, conversation := range conversations { + if conversation.AgentKey != "asset_mapper" || conversation.Title != title { + 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 + } + 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 d88cbec..9947953 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 @@ -594,6 +594,26 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera fetchGraph(); }, [fetchGraph]); + React.useEffect(() => { + let cancelled = false; + api + .assetMappingStatus(taskId) + .then((status) => { + if (cancelled) return; + setMapping(status.running); + setMappingConversationId(status.conversation_id ?? null); + }) + .catch(() => { + /* status is best effort; the graph remains usable */ + }); + setActivePath([]); + setSelectedAsset(null); + setSelectedFoldId(null); + return () => { + cancelled = true; + }; + }, [taskId]); + const { renderNodes, renderEdges } = React.useMemo( () => data @@ -631,9 +651,16 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera graph.setData(gDataRef.current); // render() 异步跑布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的 // context 上访问 transform 抛错。这是纯 teardown 竞态,吞掉它,不影响功能。 - void graph.render().catch((err) => { - if (!graph.destroyed) console.error("[coverage-graph] render:", err); - }); + void graph + .render() + .then(() => { + if (graph.destroyed) return; + const active = gDataRef.current.nodes.filter((node) => node.active).map((node) => node.id); + return active.length > 0 ? graph.focusElement(active) : graph.fitView(); + }) + .catch((err) => { + if (!graph.destroyed) console.error("[coverage-graph] render:", err); + }); }, []); // 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7b70e24..cfe1d80 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -453,6 +453,8 @@ export const api = { 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`), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), // ---- 目标管理(总览)---- From 3e04b68e38d8097c06f6931b6c010c11cf9edf2f Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:17:44 +0800 Subject: [PATCH 12/17] fix: preserve explicit topology node positions --- .../function/tasks/detail/_tabs/coverage-graph-tab.tsx | 6 ------ 1 file changed, 6 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 9947953..8a55281 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 @@ -706,12 +706,6 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera endArrow: true, }, }, - layout: { - // 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"], }); graph.on("node:click", (evt: unknown) => { From 67c0aee48348229adfe274e687d03a9443d2badd Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:49:23 +0800 Subject: [PATCH 13/17] fix: bind topology node coordinates to G6 styles --- .../function/tasks/detail/_tabs/coverage-graph-tab.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 8a55281..6eb9803 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 @@ -218,6 +218,10 @@ type G6NodeDatum = { active: boolean; x: number; y: number; + // G6 v5 reads the initial position from style.x/style.y. Keeping the + // coordinates on the datum alone makes every node start at (0, 0), after + // which fitView magnifies one node and hides the rest. + style: { x: number; y: number }; }; type G6EdgeDatum = { source: string; target: string; active: boolean }; @@ -276,6 +280,7 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDa lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`, size: 24, active: activeKeys.has(rn.key), + style: { x: position.x, y: position.y }, ...position, }; } @@ -288,6 +293,7 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDa lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label), size: kindMeta[rn.kind].size, active: activeKeys.has(rn.key), + style: { x: position.x, y: position.y }, ...position, }; }); From 6fc7317e5246e26b647d2a94996ddef400d679ac Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:02:16 +0800 Subject: [PATCH 14/17] fix: preserve topology zone columns when fitting graph --- .../function/tasks/detail/_tabs/coverage-graph-tab.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 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 6eb9803..4922182 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 @@ -662,7 +662,10 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera .then(() => { if (graph.destroyed) return; const active = gDataRef.current.nodes.filter((node) => node.active).map((node) => node.id); - return active.length > 0 ? graph.focusElement(active) : graph.fitView(); + // Keep the zone columns at their assigned coordinates. fitView rescales + // to the bounds of the current nodes, so an empty zone causes the + // remaining nodes to slide into the next column. + return active.length > 0 ? graph.focusElement(active) : graph.fitCenter(); }) .catch((err) => { if (!graph.destroyed) console.error("[coverage-graph] render:", err); @@ -679,7 +682,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera graph = new Graph({ container: containerRef.current, autoResize: true, - autoFit: "view", + autoFit: "center", background: "transparent", node: { style: { @@ -890,7 +893,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera className="h-7 px-2 text-xs" onClick={() => { setActivePath([]); - void graphRef.current?.fitView(); + void graphRef.current?.fitCenter(); }} > 清除高亮 From 5472330de44e42a74faa1b09e861959ef2160c07 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:18:10 +0800 Subject: [PATCH 15/17] feat: add topology modes and resettable viewport --- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 131 +++++++++++++++--- 1 file changed, 108 insertions(+), 23 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 4922182..ba84b7a 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 @@ -9,6 +9,7 @@ import { Link2, type LucideIcon, Radio, + RotateCcw, RefreshCw, Server, Waypoints, @@ -35,6 +36,7 @@ const FOLD_STEP = 12; type Kind = CoverageGraphNode["kind"]; type Zone = "internet" | "boundary" | "internal"; +type CoverageViewMode = "asset" | "network"; const zoneMeta: Record = { internet: { @@ -250,7 +252,11 @@ function graphLabel(n: CoverageGraphNode): string { return n.label; } -function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDatum[] { +function toG6Nodes( + renderNodes: RenderNode[], + activeKeys: Set, + viewMode: CoverageViewMode, +): G6NodeDatum[] { const buckets: Record = { internet: [], boundary: [], internal: [] }; for (const rn of renderNodes) { buckets[rn.fold ? "boundary" : zoneOf(rn.node)].push(rn); @@ -259,11 +265,20 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDa const virtualHeight = Math.max(760, maxCount * 42); const positions = new Map(); const xByZone: Record = { internet: 150, boundary: 600, internal: 1050 }; + const xByAssetKind: Partial> = { + company: 120, + root_domain: 180, + subdomain: 400, + ip: 400, + service: 680, + app: 680, + endpoint: 1000, + }; for (const zone of Object.keys(buckets) as Zone[]) { const items = buckets[zone]; items.forEach((rn, index) => { positions.set(rn.key, { - x: xByZone[zone], + x: viewMode === "network" ? xByZone[zone] : (xByAssetKind[rn.kind] ?? 600), y: 70 + ((index + 1) * (virtualHeight - 140)) / (items.length + 1), }); }); @@ -533,6 +548,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const [mapping, setMapping] = React.useState(false); const [mappingConversationId, setMappingConversationId] = React.useState(null); const [lastSyncedAt, setLastSyncedAt] = React.useState(null); + const [viewMode, setViewMode] = React.useState("asset"); + const [viewport, setViewport] = React.useState<{ zoom: number; position: [number, number] }>({ + zoom: 1, + position: [0, 0], + }); const containerRef = React.useRef(null); const graphRef = React.useRef(null); @@ -544,6 +564,24 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }); const graphDataRef = React.useRef(null); + const syncViewport = React.useCallback(() => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + const position = graph.getPosition(); + setViewport({ zoom: graph.getZoom(), position: [position[0], position[1]] }); + }, []); + + const resetView = React.useCallback(async () => { + setActivePath([]); + setSelectedAsset(null); + setSelectedFoldId(null); + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + await graph.zoomTo(1); + await graph.fitCenter(); + syncViewport(); + }, [syncViewport]); + const fetchGraph = React.useCallback(() => { setLoading(true); api @@ -631,13 +669,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。 const sig = React.useMemo( () => - `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}`, - [activePath, renderNodes, renderEdges], + `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}|${viewMode}`, + [activePath, renderNodes, renderEdges, viewMode], ); // 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。 gDataRef.current = { - nodes: toG6Nodes(renderNodes, new Set(activePath)), + nodes: toG6Nodes(renderNodes, new Set(activePath), viewMode), edges: renderEdges.map((e) => ({ // The API stores child → parent containment. Topology reads naturally // from internet entry → boundary service → internal asset, so reverse @@ -665,12 +703,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // Keep the zone columns at their assigned coordinates. fitView rescales // to the bounds of the current nodes, so an empty zone causes the // remaining nodes to slide into the next column. - return active.length > 0 ? graph.focusElement(active) : graph.fitCenter(); + const result = active.length > 0 ? graph.focusElement(active) : graph.fitCenter(); + return result.then(() => syncViewport()); }) .catch((err) => { if (!graph.destroyed) console.error("[coverage-graph] render:", err); }); - }, []); + }, [syncViewport]); // 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。 React.useEffect(() => { @@ -717,6 +756,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }, behaviors: ["drag-element", "drag-canvas", "zoom-canvas"], }); + graph.on("aftertransform", syncViewport); graph.on("node:click", (evt: unknown) => { const id = (evt as { target?: { id?: string } }).target?.id; if (!id) return; @@ -738,7 +778,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera cursor = parents.get(cursor); } setActivePath(lineage); - void graph?.focusElement(lineage); + void graph?.focusElement(lineage).then(() => syncViewport()); }); graphRef.current = graph; applyData(); @@ -754,7 +794,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera graph?.destroy(); graphRef.current = null; }; - }, [applyData]); + }, [applyData, syncViewport]); // 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。 // biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖 @@ -784,23 +824,38 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera return (
-
- {(Object.keys(zoneMeta) as Zone[]).map((zone, index) => ( + {viewMode === "network" ? ( +
-
-
{zoneMeta[zone].label}
-
{zoneMeta[zone].description}
-
+ {(Object.keys(zoneMeta) as Zone[]).map((zone, index) => ( +
+
+
{zoneMeta[zone].label}
+
{zoneMeta[zone].description}
+
+
+ ))}
- ))} -
+
+ ) : ( +
+
资产关系图
+
按资产层级展示关联关系
+
+ )}
{/* 图例 + 统计 + 刷新(叠加层) */} -
+
@@ -833,9 +888,39 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera ) : ( {loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"} )} - + +
+
+
+ +
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 &&
+ - -
+ {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}`), // ---- 目标管理(总览)----