From e35c766e6a5821b9a5ac66e7eb934ead73100fc1 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:28:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(coverage):=20=E8=A6=86=E7=9B=96=E5=9B=BE?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=94=A8=20lucide=20=E5=9B=BE=E6=A0=87=20+?= =?UTF-8?q?=20=E7=B2=BE=E7=AE=80=20label=20+=20=E6=8A=BD=E5=B1=89=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=85=B3=E8=81=94=E6=84=8F=E5=9B=BE/=E4=BA=8B?= =?UTF-8?q?=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 节点图标 emoji → 平台一致的 lucide 图标(白色 SVG data URI,iconSrc) - 图内 label:服务显示 端口·标题·状态码(不再完整 URL),端点只显示 path - 点节点抽屉新增「关联意图/关联事实/关联发现」:按 exploration_anchors 反查本任务锚定该资产的节点 - db AssetRefs(assetID);server GET /api/tasks/{id}/asset-refs?asset_id=X - types/api taskAssetRefs;抽屉按 node.asset_id 拉取渲染 Co-Authored-By: Claude Opus 4.8 --- db/exploration.go | 45 ++++++ server/server.go | 35 +++++ .../tasks/detail/_tabs/coverage-graph-tab.tsx | 134 ++++++++++++++++-- web/src/lib/api.ts | 5 +- web/src/lib/types.ts | 13 ++ 5 files changed, 218 insertions(+), 14 deletions(-) diff --git a/db/exploration.go b/db/exploration.go index 4ec4f30..4e679c5 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -615,6 +615,51 @@ AND (summary ILIKE $2 OR detail ILIKE $2) ORDER BY id LIMIT $3`, s.expID, like, return scanTrace(rows) } +// AssetRef is a compact exploration node (intent/fact/finding) that references an +// asset via an anchor — for the coverage graph's node drawer. +type AssetRef struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + State string `json:"state"` + Summary string `json:"summary"` +} + +// AssetRefs returns the intents / facts / findings in THIS exploration anchored to +// the given asset id (newest first) — what this task tested / concluded about it. +func (s *ExplorationStore) AssetRefs(assetID int64) ([]AssetRef, error) { + if assetID <= 0 { + return []AssetRef{}, nil + } + rows, err := s.db.Query(` +SELECT en.id, en.kind, en.state, en.payload +FROM exploration_anchors ea +JOIN exploration_nodes en ON en.id = ea.node_id +WHERE ea.asset_id = $1 AND en.exploration_id = $2 AND en.kind IN ('intent','fact','finding') +ORDER BY en.id DESC`, assetID, s.expID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AssetRef{} + for rows.Next() { + var r AssetRef + var payload []byte + if err := rows.Scan(&r.ID, &r.Kind, &r.State, &payload); err != nil { + return nil, err + } + var p map[string]any + _ = json.Unmarshal(payload, &p) + for _, k := range []string{"summary", "text"} { + if v, ok := p[k].(string); ok && strings.TrimSpace(v) != "" { + r.Summary = v + break + } + } + out = append(out, r) + } + return out, rows.Err() +} + // ActivityTraceSearchExcluding keyword-searches every node's steps in this // exploration EXCEPT one node's own (excludeNodeID) — so a worker's // search_all_worker_traces doesn't return its own in-progress trace (which is diff --git a/server/server.go b/server/server.go index 7c7fde9..73773f2 100644 --- a/server/server.go +++ b/server/server.go @@ -484,6 +484,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/tasks/{id}", s.getTask) mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage) mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph) + mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("POST /api/active", s.setActive) @@ -1130,6 +1131,40 @@ func (s *Server) taskCoverageGraph(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, g) } +// taskAssetRefs returns the intents / facts / findings in this task anchored to a +// given asset id — powers the coverage-graph node drawer's「关联意图 / 关联事实」。 +func (s *Server) taskAssetRefs(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + assetID, _ := strconv.ParseInt(r.URL.Query().Get("asset_id"), 10, 64) + if assetID <= 0 { + writeErr(w, 400, "需要 asset_id") + return + } + refs, err := t.Store.AssetRefs(assetID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + intents := []db.AssetRef{} + facts := []db.AssetRef{} + findings := []db.AssetRef{} + for _, ref := range refs { + switch ref.Kind { + case "intent": + intents = append(intents, ref) + case "fact": + facts = append(facts, ref) + case "finding": + findings = append(findings, ref) + } + } + writeJSON(w, 200, map[string]any{"intents": intents, "facts": facts, "findings": findings}) +} + // taskScopeList returns a task's scope rows (coverage denominator sources). func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index 17ba930..0974489 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -25,7 +25,7 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { api } from "@/lib/api"; -import type { CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; +import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; // 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。 const FOLD_LIMIT = 20; @@ -33,18 +33,58 @@ const FOLD_STEP = 20; type Kind = CoverageGraphNode["kind"]; -type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; emoji: string; size: number }; +type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number }; const kindMeta: Record = { - company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", emoji: "🏢", size: 46 }, - root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", emoji: "🌐", size: 38 }, - subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", emoji: "🔗", size: 30 }, - ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", emoji: "🖥️", size: 28 }, - service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", emoji: "📡", size: 26 }, - app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", emoji: "📱", size: 26 }, - endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", emoji: "🔌", size: 20 }, + company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", size: 46 }, + root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", size: 38 }, + subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", size: 30 }, + ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", size: 28 }, + service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", size: 26 }, + app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", size: 26 }, + endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", size: 20 }, }; +// G6 节点图标用平台一致的 lucide 图标:把 lucide 的 SVG 路径(v1.22)渲染成白色描边的 +// data URI,作为节点 iconSrc(白色在实色/灰色底上都清晰)。手写内嵌,避免 react-dom/server +// 在 React19/Next 客户端打包的问题。 +function svgUri(inner: string, filled = false): string { + const attrs = filled + ? 'fill="#fff" stroke="none"' + : 'fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; + const svg = `${inner}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +const KIND_ICON: Record = { + company: svgUri( + '', + ), + root_domain: svgUri( + '', + ), + subdomain: svgUri( + '', + ), + ip: svgUri( + '', + ), + service: svgUri( + '', + ), + app: svgUri( + '', + ), + endpoint: svgUri( + '', + ), +}; + +const FOLD_ICON = svgUri( + '', + true, +); + // --------------------------------------------------------------------------- // Folding: full graph → currently-visible node/edge set (自顶向下级联折叠)。 // --------------------------------------------------------------------------- @@ -150,6 +190,26 @@ 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 { + if (n.kind === "service") { + const parts: string[] = []; + if (n.port) parts.push(`:${n.port}`); + if (n.page_title) parts.push(n.page_title); + if (n.status_code) parts.push(String(n.status_code)); + return parts.length ? parts.join(" · ") : n.domain || n.ip || n.label; + } + if (n.kind === "endpoint" && n.url) { + try { + return new URL(n.url).pathname || "/"; + } catch { + /* 非法 URL:回退到完整 label */ + } + } + return n.label; +} + function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { return renderNodes.map((rn) => { if (rn.fold) { @@ -169,7 +229,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { fold: false, tested: rn.node.tested, inScope: rn.node.in_scope, - lbl: trunc(rn.node.label || kindMeta[rn.kind].label), + lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label), size: kindMeta[rn.kind].size, }; }); @@ -205,16 +265,56 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo ); } +function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) { + if (items.length === 0) return null; + return ( +
+

+ {title}({items.length}) +

+
+ {items.map((r) => ( +
+ #{r.id} + {r.state && {r.state}} + {r.summary || "—"} +
+ ))} +
+
+ ); +} + function AssetSheet({ node, + taskId, onOpenChange, }: { node: CoverageGraphNode | null; + taskId: string; onOpenChange: (open: boolean) => void; }) { const meta = node ? kindMeta[node.kind] : null; const Icon = meta?.icon; const raw = node ? JSON.stringify(node, null, 2) : ""; + const [refs, setRefs] = React.useState(null); + + React.useEffect(() => { + setRefs(null); + if (!node?.asset_id) return; + let cancelled = false; + api + .taskAssetRefs(taskId, node.asset_id) + .then((r) => { + if (!cancelled) setRefs(r); + }) + .catch(() => { + /* 无关联或出错:不展示该区块 */ + }); + return () => { + cancelled = true; + }; + }, [node, taskId]); return ( @@ -263,6 +363,13 @@ function AssetSheet({ {node.asset_id ? {node.asset_id} : undefined} + {refs && (refs.intents.length > 0 || refs.facts.length > 0 || refs.findings.length > 0) && ( +
+ + + +
+ )}

原始数据

@@ -424,8 +531,9 @@ function GraphInner({ taskId }: { taskId: string }) {
             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]),
-            iconText: (d: unknown) => (nd(d).fold ? "⋯" : kindMeta[nd(d).kind].emoji),
-            iconFontSize: (d: unknown) => Math.max(11, nd(d).size * 0.48),
+            iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
+            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,
             labelFontSize: 10,
             labelPlacement: "bottom",
@@ -551,7 +659,7 @@ function GraphInner({ taskId }: { taskId: string }) {
         

- !o && setSelectedAsset(null)} /> + !o && setSelectedAsset(null)} /> !o && setSelectedFoldId(null)} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 21e198f..e1f5b1d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -11,7 +11,7 @@ import type { PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, Tool, Conversation, AgentTrigger, InterceptRule, InterceptPending, InterceptApprovalRow, - TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, + TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, CommandRecord, LLMRecordItem, LLMRecordDetail, } from "@/lib/types"; @@ -127,6 +127,9 @@ export const api = { // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), + // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。 + taskAssetRefs: (id: string, assetId: number) => + get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), // ---- assets ---- // Server-side paginated: pass limit/offset, get back the page + full match total. diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index f4f299d..347d115 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -165,6 +165,19 @@ export interface CoverageGraphData { edges: CoverageGraphEdge[]; } +// 某资产在本任务探索图里关联到的意图/事实/发现(覆盖图节点抽屉用)。 +export interface CoverageAssetRef { + id: number; + kind: string; + state: string; + summary: string; +} +export interface CoverageAssetRefs { + intents: CoverageAssetRef[]; + facts: CoverageAssetRef[]; + findings: CoverageAssetRef[]; +} + // 公司资产范围规则的一条(归属唯一真值来源)。 export interface ScopeRow { id: number;