mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 01:23:50 +08:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb672389b0 | ||
|
|
21646d5d29 | ||
|
|
7d5c7101b7 | ||
|
|
5472330de4 | ||
|
|
6fc7317e52 | ||
|
|
67c0aee483 | ||
|
|
3e04b68e38 | ||
|
|
14ebf44362 | ||
|
|
495ab57ba7 | ||
|
|
d51bf259e4 | ||
|
|
6b0acf20f6 | ||
|
|
fb6942f2b2 | ||
|
|
fe939800d9 | ||
|
|
248b4d1319 | ||
|
|
252e83f13c | ||
|
|
b71c757055 | ||
|
|
d85ba36068 | ||
|
|
85ce36202c | ||
|
|
e8572a18d2 | ||
|
|
44924ec30b | ||
|
|
3c6070a8d8 | ||
|
|
404c4bb248 | ||
|
|
a999ed7cfd |
No files matched your search
+26
-6
@@ -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
|
||||
@@ -90,11 +100,20 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
|
||||
4. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。
|
||||
5. **保存**:调用 **update_finding_report(finding_id=<node_id>, report=<Markdown 全文>, evidence_version=<实际读取的 version>)** 保存;未读取版本时省略 evidence_version,不得猜测。这是你的最终产物——不写进去等于没做。
|
||||
|
||||
━━ 报告步骤截图(仅在系统设置开启时)━━
|
||||
- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。
|
||||
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:。
|
||||
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
|
||||
|
||||
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
|
||||
- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。
|
||||
- ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。
|
||||
- ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。
|
||||
- ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。
|
||||
- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。
|
||||
- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。
|
||||
- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。
|
||||
- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。
|
||||
- ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。
|
||||
- ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。
|
||||
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
|
||||
@@ -112,11 +131,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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
|
||||
+6
-1
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
-4
@@ -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/任务显式
|
||||
|
||||
+68
-20
@@ -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-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片:。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
|
||||
@@ -760,6 +766,13 @@ 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)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/assets", s.attachTaskAssets)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/assets/{assetID}", s.detachTaskAsset)
|
||||
@@ -772,6 +785,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 +3456,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 +3535,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 +3731,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 +3792,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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
legacyTitle := fmt.Sprintf("资产梳理 · 任务 #%s", t.ID)
|
||||
for _, conversation := range conversations {
|
||||
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()
|
||||
// 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})
|
||||
}
|
||||
@@ -225,6 +225,38 @@ func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, abs)
|
||||
}
|
||||
|
||||
// GET /api/workspace/preview?path=<rel> — 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=<dir> — 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"))
|
||||
|
||||
@@ -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}
|
||||
</pre>
|
||||
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
{/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */}
|
||||
{f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -10,8 +10,10 @@ 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 { 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 +23,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";
|
||||
@@ -44,6 +47,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 (
|
||||
@@ -62,6 +71,7 @@ function FindingDetailInner() {
|
||||
const [finding, setFinding] = React.useState<Finding | null>(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) {
|
||||
@@ -128,9 +138,16 @@ function FindingDetailInner() {
|
||||
}
|
||||
|
||||
const title = finding.name || finding.vulnclass || "未分类";
|
||||
const structuredReproduction = usesStructuredReproduction(finding.report || "");
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
<SideQuestionWorkspace
|
||||
side={side}
|
||||
label={`漏洞 #${finding.id} · ${title}`}
|
||||
suggestions={["请解释这个漏洞的影响范围和利用条件。", "请根据当前证据检查这个漏洞是否足以确认。", "请给出可执行的修复建议。"]}
|
||||
placeholder="询问当前漏洞…"
|
||||
>
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
{/* Sticky header */}
|
||||
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -157,6 +174,7 @@ function FindingDetailInner() {
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
<SideQuestionButton side={side} label="询问漏洞" />
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
@@ -182,156 +200,174 @@ function FindingDetailInner() {
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.evidence ? (
|
||||
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">(无证据)</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={`traffic:${id}`}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={load}
|
||||
/>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report_stale ? (
|
||||
<Alert>
|
||||
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{finding.report ? (
|
||||
<Markdown text={finding.report} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{structuredReproduction ? (
|
||||
<FindingReproductionPanel finding={finding} contextTask={contextTaskId || undefined} />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.evidence ? (
|
||||
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">(无证据)</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={`traffic:${id}`}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={load}
|
||||
/>
|
||||
<FindingRetestPanel
|
||||
key={`retest:${id}`}
|
||||
findingId={id}
|
||||
readOnly={finding.inherited}
|
||||
onCompleted={load}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report_stale ? (
|
||||
<Alert>
|
||||
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{finding.report ? (
|
||||
<Markdown text={finding.report} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右栏:状态区 */}
|
||||
<Card className="h-fit lg:sticky lg:top-24">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{/* 漏洞 ID */}
|
||||
<FieldRow label="漏洞 ID">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{finding.id}
|
||||
</code>
|
||||
</FieldRow>
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:sticky lg:top-24 lg:self-start">
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{/* 漏洞 ID */}
|
||||
<FieldRow label="漏洞 ID">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{finding.id}
|
||||
</code>
|
||||
</FieldRow>
|
||||
|
||||
{/* 严重等级 */}
|
||||
<FieldRow label="严重等级">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 严重等级 */}
|
||||
<FieldRow label="严重等级">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 处理状态 */}
|
||||
<FieldRow label="处理状态">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 处理状态 */}
|
||||
<FieldRow label="处理状态">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 漏洞类型 */}
|
||||
<FieldRow label="漏洞类型">
|
||||
{finding.vulnclass ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 漏洞类型 */}
|
||||
<FieldRow label="漏洞类型">
|
||||
{finding.vulnclass ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 涉及资产 */}
|
||||
<FieldRow label="涉及资产">
|
||||
{finding.assets && finding.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{finding.assets.map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 涉及资产 */}
|
||||
<FieldRow label="涉及资产">
|
||||
{finding.assets && finding.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{finding.assets.map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 所属任务 */}
|
||||
<FieldRow label="所属任务">
|
||||
{finding.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${finding.task_id}`}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
|
||||
title={finding.task_description}
|
||||
>
|
||||
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—(任务已删除)</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 所属任务 */}
|
||||
<FieldRow label="所属任务">
|
||||
{finding.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${finding.task_id}`}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
|
||||
title={finding.task_description}
|
||||
>
|
||||
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—(任务已删除)</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 发现时间 */}
|
||||
<FieldRow label="发现时间">
|
||||
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
|
||||
</FieldRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 发现时间 */}
|
||||
<FieldRow label="发现时间">
|
||||
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
|
||||
</FieldRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel
|
||||
key={`retest:${id}`}
|
||||
findingId={id}
|
||||
readOnly={finding.inherited}
|
||||
onCompleted={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -340,7 +376,8 @@ function FindingDetailInner() {
|
||||
<FindingLineageView findingId={finding.id} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</SideQuestionWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,21 +29,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
|
||||
@@ -80,7 +68,12 @@ function tryFormatJSON(s: string): string {
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const disabled = !text;
|
||||
const copy = async () => {
|
||||
@@ -95,7 +88,11 @@ function CopyButton({ text }: { text: string }) {
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {
|
||||
/* 忽略:不支持则静默 */
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
@@ -151,9 +148,7 @@ export default function LLMRecordsPage() {
|
||||
const reqText = showRaw
|
||||
? detail?.raw_request && tryFormatJSON(detail.raw_request)
|
||||
: detail?.request_body && tryFormatJSON(detail.request_body);
|
||||
const respText = showRaw
|
||||
? detail?.raw_response
|
||||
: detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
|
||||
// Per-task delete (task picker + confirm dialog)
|
||||
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
|
||||
@@ -167,9 +162,13 @@ export default function LLMRecordsPage() {
|
||||
let alive = true;
|
||||
api
|
||||
.settings()
|
||||
.then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
|
||||
.then((s) => {
|
||||
if (alive) setRecEnabled(!!s.llm_record);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleRecording = async (on: boolean) => {
|
||||
@@ -201,7 +200,13 @@ export default function LLMRecordsPage() {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
api
|
||||
.llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
|
||||
.llmRecords({
|
||||
model: model || undefined,
|
||||
session: sessionQ || undefined,
|
||||
task: pickedTask || undefined,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setRecords(r.records ?? []);
|
||||
@@ -211,9 +216,13 @@ export default function LLMRecordsPage() {
|
||||
.finally(() => alive && setLoading(false));
|
||||
api
|
||||
.llmTasks()
|
||||
.then((r) => { if (alive) setTasks(r.tasks ?? []); })
|
||||
.then((r) => {
|
||||
if (alive) setTasks(r.tasks ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
|
||||
|
||||
// Delete every LLM record for the picked task, then refetch.
|
||||
@@ -244,10 +253,16 @@ export default function LLMRecordsPage() {
|
||||
setDetail(null);
|
||||
api
|
||||
.llmRecordDetail(selected.id)
|
||||
.then((d) => { if (alive) setDetail(d); })
|
||||
.then((d) => {
|
||||
if (alive) setDetail(d);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (alive) setDetailLoading(false); });
|
||||
return () => { alive = false; };
|
||||
.finally(() => {
|
||||
if (alive) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
@@ -276,12 +291,7 @@ export default function LLMRecordsPage() {
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Model"
|
||||
className="h-8 w-48"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<Input placeholder="Model" className="h-8 w-48" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Select value={pickedTask} onValueChange={setPickedTask}>
|
||||
<SelectTrigger size="sm" className="w-56">
|
||||
<SelectValue placeholder="选择任务…" />
|
||||
@@ -407,15 +417,10 @@ export default function LLMRecordsPage() {
|
||||
records.map((rec) => (
|
||||
<TableRow
|
||||
key={rec.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === rec.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === rec.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(rec)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(rec.ts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(rec.ts)}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{rec.task_id ? `#${rec.task_id}` : "-"}
|
||||
</TableCell>
|
||||
@@ -442,9 +447,13 @@ export default function LLMRecordsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rec.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -474,16 +483,18 @@ export default function LLMRecordsPage() {
|
||||
任务 #{selected.task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtTime(selected.ts)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{fmtTime(selected.ts)}</span>
|
||||
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
|
||||
{fmtLatency(selected.latency_ms)}
|
||||
</span>
|
||||
{selected.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
|
||||
「原文与解析一致」。 */}
|
||||
@@ -497,12 +508,7 @@ export default function LLMRecordsPage() {
|
||||
>
|
||||
原文
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => setSelected(null)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -521,7 +527,7 @@ export default function LLMRecordsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{reqText || "(空)"}
|
||||
<HttpPacketSyntax raw={reqText || ""} lineNumbers={false} emptyFallback="(空)" />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -538,11 +544,13 @@ export default function LLMRecordsPage() {
|
||||
加载…
|
||||
</div>
|
||||
) : (
|
||||
<pre className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{respText || "(空)"}
|
||||
<pre
|
||||
className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
<HttpPacketSyntax raw={respText || ""} lineNumbers={false} emptyFallback="(空)" />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,13 @@ import type { Graph as G6Graph } from "@antv/g6";
|
||||
import {
|
||||
AppWindow,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Link2,
|
||||
type LucideIcon,
|
||||
Radio,
|
||||
RotateCcw,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Waypoints,
|
||||
@@ -29,11 +32,42 @@ 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"];
|
||||
|
||||
type Zone = "internet" | "boundary" | "internal";
|
||||
type CoverageViewMode = "asset" | "network";
|
||||
|
||||
const zoneMeta: Record<Zone, { label: string; description: string; className: string }> = {
|
||||
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 === "company" || node.kind === "root_domain" || node.kind === "subdomain" || node.kind === "ip") {
|
||||
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<Kind, KindMeta> = {
|
||||
@@ -81,6 +115,26 @@ const KIND_ICON: Record<Kind, string> = {
|
||||
),
|
||||
};
|
||||
|
||||
type DatabaseEngine = "mysql" | "sqlserver" | "postgres" | "redis" | "database";
|
||||
|
||||
const DATABASE_ICON: Record<DatabaseEngine, string> = {
|
||||
mysql: svgUri('<text x="12" y="15" text-anchor="middle" font-size="8" font-weight="700">MY</text>', true),
|
||||
sqlserver: svgUri('<text x="12" y="15" text-anchor="middle" font-size="7" font-weight="700">MS</text>', true),
|
||||
postgres: svgUri('<text x="12" y="15" text-anchor="middle" font-size="7" font-weight="700">PG</text>', true),
|
||||
redis: svgUri('<text x="12" y="15" text-anchor="middle" font-size="8" font-weight="700">RD</text>', true),
|
||||
database: svgUri('<ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v9c0 1.7 3.1 3 7 3s7-1.3 7-3V6"/><path d="M5 10c0 1.7 3.1 3 7 3s7-1.3 7-3"/>'),
|
||||
};
|
||||
|
||||
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(
|
||||
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
|
||||
true,
|
||||
@@ -185,15 +239,34 @@ type G6NodeDatum = {
|
||||
inScope: boolean;
|
||||
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
|
||||
// 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 };
|
||||
|
||||
function trunc(s: string, n = 26): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
|
||||
// 图里的节点文案:服务不显示完整 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}`);
|
||||
@@ -211,8 +284,39 @@ function graphLabel(n: CoverageGraphNode): string {
|
||||
return n.label;
|
||||
}
|
||||
|
||||
function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
function toG6Nodes(
|
||||
renderNodes: RenderNode[],
|
||||
activeKeys: Set<string>,
|
||||
viewMode: CoverageViewMode,
|
||||
): G6NodeDatum[] {
|
||||
const buckets: Record<Zone, RenderNode[]> = { internet: [], boundary: [], internal: [] };
|
||||
for (const rn of renderNodes) {
|
||||
buckets[rn.fold ? "boundary" : zoneOf(rn.node)].push(rn);
|
||||
}
|
||||
const maxCount = Math.max(buckets.internet.length, buckets.boundary.length, buckets.internal.length, 1);
|
||||
const virtualHeight = Math.max(760, maxCount * 42);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const xByZone: Record<Zone, number> = { internet: 150, boundary: 600, internal: 1050 };
|
||||
const xByAssetKind: Partial<Record<Kind, number>> = {
|
||||
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: viewMode === "network" ? xByZone[zone] : (xByAssetKind[rn.kind] ?? 600),
|
||||
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,
|
||||
@@ -220,8 +324,12 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
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,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -230,8 +338,14 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -248,6 +362,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 +579,55 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
const [expanded, setExpanded] = React.useState<Map<string, number>>(new Map());
|
||||
const [selectedAsset, setSelectedAsset] = React.useState<CoverageGraphNode | null>(null);
|
||||
const [selectedFoldId, setSelectedFoldId] = React.useState<string | null>(null);
|
||||
const [activePath, setActivePath] = React.useState<string[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [mapping, setMapping] = React.useState(false);
|
||||
const [mappingConversationId, setMappingConversationId] = React.useState<number | null>(null);
|
||||
const [networkReportAt, setNetworkReportAt] = React.useState<Date | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = React.useState<Date | null>(null);
|
||||
const [viewMode, setViewMode] = React.useState<CoverageViewMode>("asset");
|
||||
const [panelCollapsed, setPanelCollapsed] = React.useState(false);
|
||||
const [viewport, setViewport] = React.useState<{ zoom: number; position: [number, number] }>({
|
||||
zoom: 1,
|
||||
position: [0, 0],
|
||||
});
|
||||
const viewModeRef = React.useRef<CoverageViewMode>(viewMode);
|
||||
viewModeRef.current = viewMode;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const graphRef = React.useRef<G6Graph | null>(null);
|
||||
// click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。
|
||||
const renderMapRef = React.useRef<Map<string, RenderNode>>(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<typeof data>(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 (mode: CoverageViewMode = viewModeRef.current) => {
|
||||
setActivePath([]);
|
||||
setSelectedAsset(null);
|
||||
setSelectedFoldId(null);
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.destroyed) return;
|
||||
await graph.zoomTo(1);
|
||||
if (mode === "network") {
|
||||
// Network coordinates are already assigned to the three fixed zone
|
||||
// columns. Centering a graph with an empty zone shifts every node into
|
||||
// the next visible column and makes the background regions misleading.
|
||||
await graph.translateTo([0, 0]);
|
||||
} else {
|
||||
await graph.fitCenter();
|
||||
}
|
||||
syncViewport();
|
||||
}, [syncViewport]);
|
||||
|
||||
const fetchGraph = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -484,6 +638,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(() => {
|
||||
/* 保留上一次数据 */
|
||||
@@ -491,10 +647,67 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
.finally(() => setLoading(false));
|
||||
}, [taskId, coverageEnabled]);
|
||||
|
||||
const remapNetwork = React.useCallback(async () => {
|
||||
setMapping(true);
|
||||
setNetworkReportAt(null);
|
||||
try {
|
||||
const run = await api.startTopologyMapping(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);
|
||||
// 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.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mappingConversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.topologyMappingStatus(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
|
||||
@@ -503,33 +716,66 @@ 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}`,
|
||||
[renderNodes, renderEdges],
|
||||
`${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),
|
||||
edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })),
|
||||
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.
|
||||
source: e.dst,
|
||||
target: e.src,
|
||||
active: activePath.includes(e.src) && activePath.includes(e.dst),
|
||||
})),
|
||||
};
|
||||
const rmap = new Map<string, RenderNode>();
|
||||
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(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.destroyed) return;
|
||||
graph.setData(gDataRef.current);
|
||||
// render() 异步跑 d3-force 布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的
|
||||
// context 上访问 transform 抛错(见 runtime/layout transformDataAfterLayout)。这是纯
|
||||
// teardown 竞态,吞掉它,不影响功能;真正的渲染错误(图未销毁)仍打日志。
|
||||
void graph.render().catch((err) => {
|
||||
if (!graph.destroyed) console.error("[coverage-graph] render:", err);
|
||||
});
|
||||
}, []);
|
||||
// render() 异步跑布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的
|
||||
// context 上访问 transform 抛错。这是纯 teardown 竞态,吞掉它,不影响功能。
|
||||
void graph
|
||||
.render()
|
||||
.then(() => {
|
||||
if (graph.destroyed) return;
|
||||
const active = gDataRef.current.nodes.filter((node) => node.active).map((node) => node.id);
|
||||
// Keep the zone columns at their assigned coordinates. Centering a
|
||||
// network graph with an empty zone causes the remaining nodes to slide
|
||||
// into the next column, so network mode starts at the fixed origin.
|
||||
const result = active.length > 0
|
||||
? graph.focusElement(active)
|
||||
: viewModeRef.current === "network"
|
||||
? graph.translateTo([0, 0])
|
||||
: graph.fitCenter();
|
||||
return result.then(() => syncViewport());
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!graph.destroyed) console.error("[coverage-graph] render:", err);
|
||||
});
|
||||
}, [syncViewport]);
|
||||
|
||||
// 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。
|
||||
React.useEffect(() => {
|
||||
@@ -541,8 +787,9 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
graph = new Graph({
|
||||
container: containerRef.current,
|
||||
autoResize: true,
|
||||
autoFit: "view",
|
||||
background: "#f0f2f7",
|
||||
// Asset mode explicitly centers after rendering. Network mode keeps
|
||||
// its fixed world coordinates so the three background zones align.
|
||||
background: "transparent",
|
||||
node: {
|
||||
style: {
|
||||
size: (d: unknown) => nd(d).size,
|
||||
@@ -550,7 +797,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,
|
||||
@@ -561,37 +808,44 @@ 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.5 : 1,
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false },
|
||||
},
|
||||
layout: {
|
||||
type: "d3-force",
|
||||
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),
|
||||
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,
|
||||
},
|
||||
},
|
||||
behaviors: ["drag-element-force", "drag-canvas", "zoom-canvas"],
|
||||
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;
|
||||
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<string, string>();
|
||||
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);
|
||||
void graph?.focusElement(lineage).then(() => syncViewport());
|
||||
});
|
||||
graphRef.current = graph;
|
||||
applyData();
|
||||
@@ -607,7 +861,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 是刻意的重排触发依赖
|
||||
@@ -636,14 +890,73 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
<div className="relative h-full w-full overflow-hidden bg-slate-100/80 dark:bg-slate-950/30">
|
||||
{viewMode === "network" ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute top-12 left-0 grid h-[8000px] w-[1350px] grid-cols-3"
|
||||
style={{
|
||||
transform: `translate(${viewport.position[0]}px, ${viewport.position[1]}px) scale(${viewport.zoom})`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone, index) => (
|
||||
<div
|
||||
key={zone}
|
||||
className={cn("relative border-r border-slate-200/80 dark:border-slate-800/80", zoneMeta[zone].className, index === 2 && "border-r-0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-3 left-0 z-10 grid w-full grid-cols-3 px-4">
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone) => (
|
||||
<div key={zone} className="min-w-0">
|
||||
<div className="text-foreground/80 text-sm font-semibold">{zoneMeta[zone].label}</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">{zoneMeta[zone].description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute top-3 left-4 z-0">
|
||||
<div className="text-foreground/80 text-sm font-semibold">资产关系图</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">按资产层级展示关联关系</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="relative z-10 h-full w-full pt-12" />
|
||||
|
||||
{/* 图例 + 统计 + 刷新(叠加层) */}
|
||||
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 flex max-w-[340px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
|
||||
<div className={cn(
|
||||
"bg-card/95 pointer-events-auto absolute bottom-3 left-3 z-20 rounded-lg border shadow-sm backdrop-blur",
|
||||
panelCollapsed
|
||||
? "flex items-center gap-2 px-2.5 py-2 text-xs"
|
||||
: "flex w-[min(480px,calc(100%-1.5rem))] max-h-[calc(100%-1.5rem)] flex-col gap-2.5 overflow-auto p-3 text-xs",
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-700 dark:bg-violet-950/60 dark:text-violet-300">
|
||||
<Waypoints className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-foreground truncate font-medium">
|
||||
{viewMode === "network" ? "网络拓扑梳理 Agent" : "资产关系图"}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{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" })}`
|
||||
: "等待读取资产库"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{total > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
|
||||
{coverageEnabled && (
|
||||
<>
|
||||
@@ -656,12 +969,54 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
) : (
|
||||
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="size-6" onClick={fetchGraph} title="刷新">
|
||||
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
|
||||
{!panelCollapsed && <div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setPanelCollapsed(true)} title="收起面板">
|
||||
<ChevronDown className="size-3.5" />
|
||||
<span className="hidden sm:inline">收起</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void resetView()} title="重置视图">
|
||||
<RotateCcw className="size-3.5" />
|
||||
<span className="hidden sm:inline">重置视图</span>
|
||||
</Button>
|
||||
{viewMode === "network" && (
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void remapNetwork()} disabled={mapping} title="重新梳理网络拓扑">
|
||||
<RefreshCw className={cn("size-3.5", (loading || mapping) && "animate-spin")} />
|
||||
<span className="hidden sm:inline">{mapping ? "正在梳理网络…" : "梳理网络拓扑"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
{panelCollapsed && <Button variant="ghost" size="sm" className="h-7 gap-1 px-2" onClick={() => setPanelCollapsed(false)} title="展开面板">
|
||||
<ChevronUp className="size-3.5" />
|
||||
<span className="hidden sm:inline">展开</span>
|
||||
</Button>}
|
||||
</div>
|
||||
{!panelCollapsed && <>
|
||||
<div className="bg-muted/60 flex items-center gap-1 rounded-md p-1">
|
||||
<Button
|
||||
variant={viewMode === "asset" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 flex-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setViewMode("asset");
|
||||
void resetView("asset");
|
||||
}}
|
||||
>
|
||||
资产关系图
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "network" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 flex-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setViewMode("network");
|
||||
void resetView("network");
|
||||
}}
|
||||
>
|
||||
网络拓扑
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
{(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 (
|
||||
@@ -689,11 +1044,62 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" /> 范围外
|
||||
</span>
|
||||
</div>
|
||||
{viewMode === "network" && (
|
||||
<div className="border-border/60 flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone) => (
|
||||
<span key={zone} className="text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-2.5 rounded-full", zone === "internet" && "bg-sky-400", zone === "boundary" && "bg-amber-400", zone === "internal" && "bg-emerald-400")} />
|
||||
{zoneMeta[zone].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground/80 border-border/60 border-t pt-2 leading-relaxed">
|
||||
力导向布局,可拖拽节点、滚轮缩放;灰色「⋯」是折叠节点,点开可展开更多。
|
||||
{viewMode === "network"
|
||||
? "网络拓扑梳理会话会输出资产关系结果和网络拓扑结果;图中只展示有真实资产证据的节点及区域。"
|
||||
: "资产关系图直接读取资产库,不需要额外梳理;点击节点查看详情并高亮关系链。灰色「⋯」可展开更多资产。"}
|
||||
</p>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{selectedAsset && activePath.length > 0 && data && (
|
||||
<div className="bg-card/95 pointer-events-auto absolute top-3 right-3 z-20 w-[min(310px,calc(100%-2rem))] rounded-lg border p-3 shadow-sm backdrop-blur">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-foreground text-sm font-semibold">关系路径</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">从当前资产向上追溯关联关系</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setActivePath([]);
|
||||
void graphRef.current?.fitCenter();
|
||||
}}
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{[...activePath].reverse().map((key, index, path) => {
|
||||
const node = data.nodes.find((item) => item.key === key);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md bg-muted/60 px-2 py-1.5 text-xs">
|
||||
<span className={cn("size-2 shrink-0 rounded-full", zoneOf(node) === "internet" && "bg-sky-500", zoneOf(node) === "boundary" && "bg-amber-500", zoneOf(node) === "internal" && "bg-emerald-500")} />
|
||||
<span className="text-muted-foreground shrink-0">{zoneMeta[zoneOf(node)].label}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono" title={node.label}>{node.label}</span>
|
||||
</div>
|
||||
{index < path.length - 1 && <div className="text-muted-foreground pl-3 text-[10px]">↓</div>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssetSheet node={selectedAsset} taskId={taskId} onOpenChange={(o) => !o && setSelectedAsset(null)} />
|
||||
<FoldSheet
|
||||
fold={selectedFold}
|
||||
|
||||
@@ -43,7 +43,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { SortableHead } from "@/components/ui/sortable-head";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
@@ -774,7 +773,7 @@ export default function TrafficPage() {
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
@@ -797,32 +796,24 @@ export default function TrafficPage() {
|
||||
{importing ? "转入中…" : "送入请求重发"}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
|
||||
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="response" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={detail?.resp ?? ""} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
|
||||
{[
|
||||
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
|
||||
["response", "响应 Response", detail?.resp ?? ""],
|
||||
].map(([key, label, raw]) => (
|
||||
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
|
||||
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
|
||||
{detailLoading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={raw} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
||||
@@ -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<Settings> =
|
||||
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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CameraIcon className="size-4" />
|
||||
浏览器截图
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,报告撰写 Agent 会自动获得 browser
|
||||
MCP,并在复现步骤对应的页面上保存截图,报告中直接显示对应步骤截图。 截图会增加 Token
|
||||
消耗;关闭后不会自动给报告撰写 Agent 授权截图能力,已有历史报告保留原截图。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="browser-screenshot" className="text-sm font-normal text-muted-foreground">
|
||||
{browserScreenshot ? "已开启 · 报告撰写 Agent 自动截图" : "已关闭 · 报告不自动截图"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="browser-screenshot"
|
||||
checked={browserScreenshot}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={toggleBrowserScreenshot}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -510,9 +559,8 @@ export default function SystemSettingsPage() {
|
||||
开启<b>流量捕获</b>时,它作为记录代理的<b>上游</b>(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入
|
||||
Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
|
||||
<br />
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl
|
||||
可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM
|
||||
亲自拨号,工具无感知、稳定生效。
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl 可用,python
|
||||
urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
@@ -537,12 +585,7 @@ export default function SystemSettingsPage() {
|
||||
保存
|
||||
</Button>
|
||||
{/* 测表单里当前的值,不要求先保存。 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testEgress}
|
||||
disabled={!loaded || egressTesting}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={testEgress} disabled={!loaded || egressTesting}>
|
||||
{egressTesting ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -567,15 +610,13 @@ export default function SystemSettingsPage() {
|
||||
</div>
|
||||
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
|
||||
<code>ifconfig.me</code>),它返回"这个请求从哪个 IP 来"。
|
||||
① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。
|
||||
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换 <code>ifconfig.me</code>
|
||||
),它返回"这个请求从哪个 IP 来"。 ① 是不走代理时的 IP,② 是经全局代理出网时的
|
||||
IP。改完不用先保存,直接测。
|
||||
</p>
|
||||
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
|
||||
<details className="text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">
|
||||
自定义 IP 探测服务(可选)
|
||||
</summary>
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">自定义 IP 探测服务(可选)</summary>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -587,10 +628,9 @@ export default function SystemSettingsPage() {
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground leading-relaxed">
|
||||
自定义 IP 探测服务需返回纯文本 IP 或{" "}
|
||||
<code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口,
|
||||
则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。
|
||||
自定义 IP 探测服务需返回纯文本 IP 或 <code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ① 和 ②
|
||||
的 IP 不同,就说明流量走了内网代理。
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
@@ -603,11 +643,7 @@ export default function SystemSettingsPage() {
|
||||
<div key={c.key} className="flex flex-wrap items-center gap-x-2">
|
||||
<span
|
||||
className={
|
||||
c.skipped
|
||||
? "text-muted-foreground"
|
||||
: c.result.ok
|
||||
? "text-emerald-600"
|
||||
: "text-destructive"
|
||||
c.skipped ? "text-muted-foreground" : c.result.ok ? "text-emerald-600" : "text-destructive"
|
||||
}
|
||||
>
|
||||
{c.skipped ? "—" : c.result.ok ? "✓" : "✗"}
|
||||
@@ -629,18 +665,12 @@ export default function SystemSettingsPage() {
|
||||
<span className="text-muted-foreground">{c.skip_reason}</span>
|
||||
) : c.result.ok ? (
|
||||
<span className="font-mono">
|
||||
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">
|
||||
{c.result.exit_ip}
|
||||
</span>{" "}
|
||||
·{" "}
|
||||
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">{c.result.exit_ip}</span> ·{" "}
|
||||
<span title="从 ARTEX 发出请求到拿到响应的完整往返耗时;经代理时包含代理那一跳">
|
||||
{c.result.latency_ms}ms
|
||||
</span>
|
||||
{c.result.via && c.result.via !== "direct" && !viaCapture && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· 经 {c.result.via_label ?? c.result.via}
|
||||
</span>
|
||||
<span className="text-muted-foreground"> · 经 {c.result.via_label ?? c.result.via}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
@@ -733,7 +763,9 @@ export default function SystemSettingsPage() {
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>自动标题</CardTitle>
|
||||
<CardDescription>为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。</CardDescription>
|
||||
<CardDescription>
|
||||
为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field orientation="horizontal">
|
||||
@@ -751,7 +783,9 @@ export default function SystemSettingsPage() {
|
||||
<Field orientation="horizontal" className="mt-5">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="chat-auto-title">会话标题</FieldLabel>
|
||||
<FieldDescription>对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。</FieldDescription>
|
||||
<FieldDescription>
|
||||
对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id="chat-auto-title"
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
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 { toneClasses } from "@/lib/status";
|
||||
import type { CredentialAccount, CredentialTraffic, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types";
|
||||
@@ -344,26 +343,22 @@ export function CredentialTrafficViewer({
|
||||
{data.reference.side === "request" ? "请求包含匹配凭据" : "响应包含匹配凭据"},不代表认证验证成功。
|
||||
</p>
|
||||
<p className="text-sm">绑定时保存的匹配片段</p>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3 text-xs">
|
||||
{data.reference.excerpt}
|
||||
</pre>
|
||||
<HttpCodeBlock raw={data.reference.excerpt} />
|
||||
{data.unavailable ? (
|
||||
<Alert>
|
||||
<AlertDescription>原始流量已删除或不可用,仍可查看上方保存的匹配片段。</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue={data.reference.side}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求</TabsTrigger>
|
||||
<TabsTrigger value="response">响应</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
|
||||
<HttpCodeBlock raw={data.req ?? ""} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
|
||||
<HttpCodeBlock raw={data.resp ?? ""} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
|
||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingTrafficBinding, FindingTrafficDetail } from "@/lib/types";
|
||||
|
||||
type ReproductionStep = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function extractSection(report: string) {
|
||||
const match = report.match(/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im);
|
||||
if (!match || match.index === undefined) return "";
|
||||
const start = match.index + match[0].length;
|
||||
const tail = report.slice(start);
|
||||
const end = tail.search(/^##\s+/m);
|
||||
return (end >= 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<FindingTrafficDetail | null>(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 <p className="mt-3 border-t pt-3 text-xs text-muted-foreground">此步骤暂无已绑定的真实流量证据。</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
{open ? "收起数据包" : "查看数据包"}
|
||||
</Button>
|
||||
{open && detail ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={repeaterBusy}
|
||||
onClick={() => void sendToRepeater()}
|
||||
>
|
||||
{repeaterBusy ? "准备中…" : "送入请求重发"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 p-3">
|
||||
{error ? <p className="text-destructive text-xs">数据包加载失败:{error}</p> : null}
|
||||
{!error && bindingId && !detail ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" /> 正在读取已保存的真实流量…
|
||||
</div>
|
||||
) : null}
|
||||
{detail ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="secondary">{detail.binding.snapshot.method}</Badge>
|
||||
<Badge variant="outline">{detail.binding.snapshot.status}</Badge>
|
||||
<span className="break-all text-muted-foreground">{detail.binding.snapshot.url}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] text-muted-foreground">请求 Request</p>
|
||||
<HttpCodeBlock raw={packetText(detail, "request")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] text-muted-foreground">响应 Response</p>
|
||||
<HttpCodeBlock raw={packetText(detail, "response")} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<FindingTrafficBinding[] | undefined>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>复现步骤</CardTitle>
|
||||
<CardDescription>步骤同时展示,数据包默认隐藏;需要核对原始请求时再按步骤展开。</CardDescription>
|
||||
</div>
|
||||
<Badge variant={steps.length > 0 ? "secondary" : "outline"}>
|
||||
{steps.length} 个步骤 · {traffic?.length ?? finding.traffic_count ?? 0} 个数据包
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{overview ? (
|
||||
<div className="rounded-lg bg-muted/30 px-3 py-2.5 text-sm">
|
||||
<Markdown text={overview} />
|
||||
</div>
|
||||
) : null}
|
||||
{steps.map((step, index) => {
|
||||
const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index];
|
||||
return (
|
||||
<div key={`${step.number}-${step.title}`} className="relative rounded-lg border bg-background p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="outline" className="shrink-0 font-medium">
|
||||
步骤 {step.number}
|
||||
</Badge>
|
||||
<h3 className="min-w-0 break-words font-semibold text-sm leading-5">{step.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{step.body ? <Markdown text={step.body} /> : null}
|
||||
<PacketDisclosure findingId={finding.id} bindingId={binding?.id} contextTask={contextTask} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{outcome || rootCause || remediation ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{outcome ? <Markdown text={`## 最终结果\n\n${outcome}`} /> : null}
|
||||
{rootCause ? <Markdown text={`## 根因分析\n\n${rootCause}`} /> : null}
|
||||
{remediation ? <Markdown text={`## 修复建议\n\n${remediation}`} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, SearchIcon, WrapTextIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,15 +16,55 @@ function statusTone(status: number) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
const PACKET_COLORS = {
|
||||
method: "font-semibold text-violet-600 dark:text-violet-400",
|
||||
url: "text-sky-600 dark:text-sky-400",
|
||||
dim: "text-muted-foreground",
|
||||
headerName: "text-sky-700 dark:text-sky-300",
|
||||
headerValue: "text-emerald-600 dark:text-emerald-400",
|
||||
jsonKey: "text-sky-700 dark:text-sky-300",
|
||||
jsonString: "text-amber-600 dark:text-amber-400",
|
||||
jsonNumber: "text-fuchsia-600 dark:text-fuchsia-400",
|
||||
jsonBoolean: "text-rose-600 dark:text-rose-400",
|
||||
tag: "text-sky-600 dark:text-sky-400",
|
||||
};
|
||||
|
||||
const PacketHighlightContext = React.createContext("");
|
||||
|
||||
function HighlightedText({ text, className }: { text: string; className?: string }) {
|
||||
const query = React.useContext(PacketHighlightContext).trim();
|
||||
if (!query || !text) return <span className={className}>{text}</span>;
|
||||
const needle = query.toLowerCase();
|
||||
const haystack = text.toLowerCase();
|
||||
const parts: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let key = 0;
|
||||
for (;;) {
|
||||
const match = haystack.indexOf(needle, cursor);
|
||||
if (match < 0) {
|
||||
parts.push(text.slice(cursor));
|
||||
break;
|
||||
}
|
||||
if (match > cursor) parts.push(text.slice(cursor, match));
|
||||
parts.push(
|
||||
<mark key={key++} className="rounded-[2px] bg-yellow-300 text-black">
|
||||
{text.slice(match, match + needle.length)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = match + needle.length;
|
||||
}
|
||||
return <span className={className}>{parts}</span>;
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{response[1]}</span>
|
||||
<HighlightedText text={response[1]} className={PACKET_COLORS.dim} />
|
||||
{response[2]}
|
||||
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
|
||||
{response[4]}
|
||||
<HighlightedText text={response[3]} className={statusTone(Number(response[3]))} />
|
||||
<HighlightedText text={response[4]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,26 +73,26 @@ function StartLine({ line }: { line: string }) {
|
||||
if (request) {
|
||||
return (
|
||||
<>
|
||||
<span className="font-semibold text-primary">{request[1]}</span>
|
||||
<HighlightedText text={request[1]} className={PACKET_COLORS.method} />
|
||||
{request[2]}
|
||||
<span className="text-chart-2">{request[3]}</span>
|
||||
<HighlightedText text={request[3]} className={PACKET_COLORS.url} />
|
||||
{request[4]}
|
||||
<span className="text-muted-foreground">{request[5]}</span>
|
||||
<HighlightedText text={request[5]} className={PACKET_COLORS.dim} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
return <HighlightedText text={line} />;
|
||||
}
|
||||
|
||||
function HeaderLine({ line }: { line: string }) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) return line;
|
||||
if (separator <= 0) return <HighlightedText text={line} />;
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary">{line.slice(0, separator)}</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-chart-2">{line.slice(separator + 1)}</span>
|
||||
<HighlightedText text={line.slice(0, separator)} className={PACKET_COLORS.headerName} />
|
||||
<span className={PACKET_COLORS.dim}>:</span>
|
||||
<HighlightedText text={line.slice(separator + 1)} className={PACKET_COLORS.headerValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -63,18 +103,14 @@ function JsonBody({ body }: { body: string }) {
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tokens)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
let className = "text-chart-4";
|
||||
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
|
||||
else if (match[3]) className = "text-chart-3";
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className={className}>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
|
||||
let className = PACKET_COLORS.jsonNumber;
|
||||
if (match[1]) className = match[2] ? PACKET_COLORS.jsonKey : PACKET_COLORS.jsonString;
|
||||
else if (match[3]) className = PACKET_COLORS.jsonBoolean;
|
||||
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={className} />);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -84,15 +120,11 @@ function MarkupBody({ body }: { body: string }) {
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tags)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className="text-primary">
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
|
||||
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={PACKET_COLORS.tag} />);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -108,16 +140,9 @@ function detectBodyFormat(body: string): BodyFormat {
|
||||
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
|
||||
if (format === "json") return <JsonBody body={body} />;
|
||||
if (format === "markup") return <MarkupBody body={body} />;
|
||||
return body;
|
||||
return <HighlightedText text={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,
|
||||
@@ -133,10 +158,15 @@ export function HttpPacketSyntax({
|
||||
}) {
|
||||
const value = raw || emptyFallback;
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const lineItems = React.useMemo(() => {
|
||||
const occurrences = new Map<string, number>();
|
||||
return lines.map((line) => {
|
||||
const occurrence = occurrences.get(line) ?? 0;
|
||||
occurrences.set(line, occurrence + 1);
|
||||
return { key: `${line}\u0000${occurrence}`, line };
|
||||
});
|
||||
}, [value]);
|
||||
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) => {
|
||||
@@ -146,28 +176,27 @@ export function HttpPacketSyntax({
|
||||
if (index === separator) return null;
|
||||
return <HighlightedBody body={line} format={bodyFormat} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
{lineItems.map((item, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
key={item.key}
|
||||
data-line={index + 1}
|
||||
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[1.5rem_minmax(0,1fr)]")}
|
||||
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[2.25rem_minmax(0,1fr)]")}
|
||||
>
|
||||
{lineNumbers ? (
|
||||
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-muted-foreground/60">
|
||||
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-[11px] text-muted-foreground/50">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : null}
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 [tab-size:4]",
|
||||
lineNumbers ? "pr-16 pl-1.5" : "",
|
||||
lineNumbers ? "pr-14 pl-2" : "",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(line, index)}
|
||||
{renderLine(item.line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
@@ -175,6 +204,20 @@ export function HttpPacketSyntax({
|
||||
);
|
||||
}
|
||||
|
||||
function countMatches(value: string, query: string) {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return 0;
|
||||
const haystack = value.toLowerCase();
|
||||
let count = 0;
|
||||
let cursor = 0;
|
||||
while (cursor <= haystack.length - needle.length) {
|
||||
const index = haystack.indexOf(needle, cursor);
|
||||
if (index < 0) return count;
|
||||
count += 1;
|
||||
cursor = index + needle.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -212,8 +255,10 @@ function selectedPacketText(container: HTMLElement, value: string, lines: string
|
||||
export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const value = raw || "(空)";
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const matches = React.useMemo(() => countMatches(value, query), [value, query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
@@ -231,7 +276,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
|
||||
<div className="relative mx-3 my-4 flex min-h-0 flex-col overflow-hidden rounded-md border bg-background shadow-xs">
|
||||
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -270,7 +315,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
aria-multiline="true"
|
||||
aria-readonly="true"
|
||||
tabIndex={0}
|
||||
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onCopy={(event) => {
|
||||
const text = selectedPacketText(event.currentTarget, value, lines);
|
||||
if (text === null) return;
|
||||
@@ -288,7 +333,29 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
selection.addRange(range);
|
||||
}}
|
||||
>
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
<PacketHighlightContext.Provider value={query}>
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
</PacketHighlightContext.Provider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
|
||||
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索报文内容"
|
||||
aria-label="搜索报文内容"
|
||||
className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
{query.trim() ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs tabular-nums",
|
||||
matches ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{matches} 处命中
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,21 @@ const components: Components = {
|
||||
a: ({ node, ...p }) => (
|
||||
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...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 (
|
||||
<img
|
||||
className="my-2 max-h-[520px] max-w-full rounded-md border object-contain"
|
||||
src={resolvedSrc}
|
||||
alt={alt || "报告截图"}
|
||||
loading="lazy"
|
||||
{...p}
|
||||
/>
|
||||
);
|
||||
},
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
blockquote: ({ node, ...p }) => (
|
||||
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
|
||||
@@ -42,9 +57,7 @@ const components: Components = {
|
||||
<table className="w-full border-collapse text-xs" {...p} />
|
||||
</div>
|
||||
),
|
||||
th: ({ node, ...p }) => (
|
||||
<th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />
|
||||
),
|
||||
th: ({ node, ...p }) => <th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />,
|
||||
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw 旁路提问">
|
||||
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title={`/btw ${label}`}>
|
||||
<MessageCircleQuestionIcon data-icon="inline-start" />
|
||||
旁路提问
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div key={side} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{side === "request" ? "请求 Request" : "响应 Response"}</p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -134,7 +132,7 @@ export function TrafficEvidenceViewer({
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
下载完整正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
@@ -145,9 +143,9 @@ export function TrafficEvidenceViewer({
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</div>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
@@ -193,18 +191,16 @@ export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onCl
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
|
||||
<HttpCodeBlock raw={detail.req} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
|
||||
<HttpCodeBlock raw={detail.resp} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
|
||||
@@ -451,6 +451,10 @@ export const api = {
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
startTopologyMapping: (id: string) =>
|
||||
post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/topology-mapping`, {}),
|
||||
topologyMappingStatus: (id: string) =>
|
||||
get<{ running: boolean; conversation_id?: number }>(`/tasks/${id}/topology-mapping/status`),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// ---- 目标管理(总览)----
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user