From c396674d2b28a69942d360caacf0eb116f29ef79 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:30:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(coverage):=20=E4=BB=BB=E5=8A=A1=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E8=B5=84=E4=BA=A7=E8=A6=86=E7=9B=96=E5=9B=BE?= =?UTF-8?q?=E3=80=8D=E5=8A=9B=E5=AF=BC=E5=90=91=E5=9B=BE=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端(只读,无迁移): - db BuildCoverageGraph(taskID,expID) 复用 covTargetCTE 拉范围内全部类型资产 + tested 标记,补连接节点(缺失根域名/公司,灰),按字段派生 child→parent 边 - server GET /api/tasks/{id}/coverage-graph 前端: - 新 tab coverage-graph-tab:xyflow 渲染 + d3-force 静态力导向布局 - 已测=实色高亮、范围内未测=灰、范围外连接节点=灰虚线;点节点看详情抽屉 - 折叠/分页:同父同类型子节点默认展示 20(已测优先),超出收进折叠节点, 抽屉「展示更多(+20)」把下一批拉进图;布局仅在可见集合变化时重算 - types/api + d3-force 依赖声明 + 注册 tab Co-Authored-By: Claude Opus 4.8 --- db/task_scope.go | 248 +++++++ server/server.go | 23 + web/package-lock.json | 9 +- web/package.json | 2 + .../tasks/detail/_tabs/coverage-graph-tab.tsx | 651 ++++++++++++++++++ .../app/(main)/function/tasks/detail/page.tsx | 5 + web/src/lib/api.ts | 5 +- web/src/lib/types.ts | 32 + 8 files changed, 970 insertions(+), 5 deletions(-) create mode 100644 web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx diff --git a/db/task_scope.go b/db/task_scope.go index 716425f..dee0c61 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -273,6 +273,254 @@ FROM target t GROUP BY t.type ORDER BY t.type`, taskID, expID) return cov, nil } +// --------------------------------------------------------------------------- +// Coverage graph — a force-directed view of a task's in-scope assets. +// --------------------------------------------------------------------------- + +// CoverageGraphNode is one node of the asset coverage graph. Node identity is a +// string key: an asset row is "a:", a company is "c:", and a root domain +// with no asset row of its own is the synthetic "r:". Out-of-scope +// connector nodes (roots pulled in only to link subdomains, and the companies +// above them) carry InScope=false and are rendered gray. +type CoverageGraphNode struct { + Key string `json:"key"` + Kind string `json:"kind"` // company|root_domain|subdomain|ip|service|app|endpoint + Label string `json:"label"` + Tested bool `json:"tested"` + InScope bool `json:"in_scope"` + AssetID int64 `json:"asset_id,omitempty"` // 0 for company / synthetic root + CompanyID int64 `json:"company_id,omitempty"` + Domain string `json:"domain,omitempty"` + RootDomain string `json:"root_domain,omitempty"` + IP string `json:"ip,omitempty"` + URL string `json:"url,omitempty"` + Port int `json:"port,omitempty"` + ServiceType string `json:"service_type,omitempty"` + AppName string `json:"app_name,omitempty"` + PageTitle string `json:"page_title,omitempty"` + StatusCode int `json:"status_code,omitempty"` +} + +// CoverageGraphEdge is a child→parent containment link (endpoint→service→ +// subdomain/ip→root_domain→company, app→company). +type CoverageGraphEdge struct { + Src string `json:"src"` + Dst string `json:"dst"` +} + +// CoverageGraphData is the whole graph for one task. +type CoverageGraphData struct { + Nodes []CoverageGraphNode `json:"nodes"` + Edges []CoverageGraphEdge `json:"edges"` +} + +func assetKey(id int64) string { return "a:" + strconv.FormatInt(id, 10) } +func companyKey(id int64) string { return "c:" + strconv.FormatInt(id, 10) } + +// hostPortOf returns the (host, port) a service / endpoint node hangs off — the +// domain if set, otherwise the URL host, otherwise the IP. +func hostPortOf(n *CoverageGraphNode) (string, int) { + host, port := n.Domain, n.Port + if host == "" && n.URL != "" { + h, p, _ := parseURL(normalizeURL(n.URL)) + host = h + if port == 0 { + port = p + } + } + if host == "" { + host = n.IP + } + return host, port +} + +// BuildCoverageGraph assembles the full coverage graph for a task: every in-scope +// asset (all types) plus the connector root domains / companies needed to link +// them, with a tested flag on each in-scope asset. The frontend does the folding +// and highlighting; this only returns nodes + derived containment edges. +func (s *AssetStore) BuildCoverageGraph(taskID, expID int64) (*CoverageGraphData, error) { + g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}} + if taskID <= 0 { + return g, nil + } + rows, err := s.db.Query(`WITH `+covTargetCTE+` +SELECT a.id, a.type, COALESCE(a.company_id,0), + COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''), + COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''), + COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0), + (a.id IN (SELECT asset_id FROM tested)) AS tested +FROM assets a JOIN target t ON t.id = a.id +ORDER BY a.id`, taskID, expID) + if err != nil { + return nil, err + } + defer rows.Close() + + byKey := map[string]*CoverageGraphNode{} + rootByDomain := map[string]string{} // root domain → node key + subByDomain := map[string]string{} // subdomain → node key + ipByAddr := map[string]string{} // ip literal → node key + svcByHostPort := map[string]string{} + svcByHost := map[string]string{} + companyIDs := map[int64]bool{} // referenced company ids (need a node) + + add := func(n CoverageGraphNode) *CoverageGraphNode { + if _, ok := byKey[n.Key]; ok { + return byKey[n.Key] + } + g.Nodes = append(g.Nodes, n) + p := &g.Nodes[len(g.Nodes)-1] + byKey[n.Key] = p + return p + } + + for rows.Next() { + var n CoverageGraphNode + var companyID int64 + if err := rows.Scan(&n.AssetID, &n.Kind, &companyID, + &n.Domain, &n.RootDomain, &n.IP, &n.URL, &n.Port, &n.ServiceType, + &n.AppName, &n.PageTitle, &n.StatusCode, &n.Tested); err != nil { + return nil, err + } + n.Key = assetKey(n.AssetID) + n.CompanyID = companyID + n.InScope = true + n.Label = coverageNodeLabel(&n) + p := add(n) + switch n.Kind { + case "root_domain": + if n.Domain != "" { + rootByDomain[n.Domain] = p.Key + } + case "subdomain": + if n.Domain != "" { + subByDomain[n.Domain] = p.Key + } + case "ip": + if n.IP != "" { + ipByAddr[n.IP] = p.Key + } + case "service": + host, port := hostPortOf(p) + if host != "" { + svcByHost[host] = p.Key + svcByHostPort[host+"|"+strconv.Itoa(port)] = p.Key + } + } + if companyID > 0 { + companyIDs[companyID] = true + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Connector root domains: any subdomain whose root domain is not itself an + // in-scope node. Pull the real asset row if one exists (so the drawer shows + // real detail), else synthesize a bare "r:" placeholder. Both gray. + missingRoots := map[string]bool{} + for _, n := range g.Nodes { + if n.Kind == "subdomain" && n.RootDomain != "" { + if _, ok := rootByDomain[n.RootDomain]; !ok { + missingRoots[n.RootDomain] = true + } + } + } + for root := range missingRoots { + var id, companyID int64 + err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets +WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID) + var node CoverageGraphNode + if err == nil && id > 0 { + node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id, + CompanyID: companyID, Domain: root, Label: root} + if companyID > 0 { + companyIDs[companyID] = true + } + } else { + node = CoverageGraphNode{Key: "r:" + root, Kind: "root_domain", Domain: root, Label: root} + } + add(node) + rootByDomain[root] = node.Key + } + + // Company nodes for every referenced company id — always gray context. + for id := range companyIDs { + key := companyKey(id) + if _, ok := byKey[key]; ok { + continue + } + var name string + if err := s.db.QueryRow(`SELECT name FROM companies WHERE id=$1`, id).Scan(&name); err != nil { + continue + } + add(CoverageGraphNode{Key: key, Kind: "company", CompanyID: id, + Label: name, AssetID: 0}) + } + + // Derived containment edges (only when the parent node exists). + link := func(childKey, parentKey string) { + if parentKey == "" || parentKey == childKey { + return + } + if _, ok := byKey[parentKey]; !ok { + return + } + g.Edges = append(g.Edges, CoverageGraphEdge{Src: childKey, Dst: parentKey}) + } + firstOf := func(keys ...string) string { + for _, k := range keys { + if k != "" { + if _, ok := byKey[k]; ok { + return k + } + } + } + return "" + } + for i := range g.Nodes { + n := &g.Nodes[i] + switch n.Kind { + case "subdomain": + link(n.Key, rootByDomain[n.RootDomain]) + case "root_domain", "app", "ip": + if n.CompanyID > 0 { + link(n.Key, companyKey(n.CompanyID)) + } + case "service": + link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain])) + case "endpoint": + host, port := hostPortOf(n) + parent := firstOf( + svcByHostPort[host+"|"+strconv.Itoa(port)], svcByHost[host], + subByDomain[host], subByDomain[n.Domain], ipByAddr[host], ipByAddr[n.IP], + rootByDomain[n.RootDomain]) + link(n.Key, parent) + } + } + return g, nil +} + +// coverageNodeLabel picks the human label for a coverage-graph node. +func coverageNodeLabel(n *CoverageGraphNode) string { + switch n.Kind { + case "endpoint", "service": + if n.URL != "" { + return n.URL + } + case "app": + if n.AppName != "" { + return n.AppName + } + } + for _, v := range []string{n.URL, n.Domain, n.IP, n.AppName, n.RootDomain} { + if v != "" { + return v + } + } + return n.Key +} + // ListUntestedAssets returns a task's in-scope, not-yet-tested assets, optionally // filtered by asset type, paginated. Returns the page + the total count. limit<=0 → 10. func (s *AssetStore) ListUntestedAssets(taskID, expID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) { diff --git a/server/server.go b/server/server.go index 07045ee..7c7fde9 100644 --- a/server/server.go +++ b/server/server.go @@ -483,6 +483,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/tasks", s.createTask) 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}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("POST /api/active", s.setActive) @@ -1107,6 +1108,28 @@ func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, cov) } +// taskCoverageGraph returns the force-directed asset coverage graph for a task: +// all in-scope assets (每种类型) + 连接用的根域名/公司节点, each carrying tested/in_scope. +func (s *Server) taskCoverageGraph(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + g, err := as.BuildCoverageGraph(taskID, t.ExpID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, g) +} + // 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/package-lock.json b/web/package-lock.json index 493b756..73c5fe9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", @@ -52,6 +53,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.1", "@tailwindcss/postcss": "^4.3.2", + "@types/d3-force": "^3.0.10", "@types/d3-geo": "^3.1.0", "@types/node": "^22.20.0", "@types/react": "^19.2.17", @@ -4253,7 +4255,6 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4263,7 +4264,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4556,7 +4557,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" @@ -10600,7 +10601,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/web/package.json b/web/package.json index 7c27fb7..d7f2288 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", @@ -64,6 +65,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.1", "@tailwindcss/postcss": "^4.3.2", + "@types/d3-force": "^3.0.10", "@types/d3-geo": "^3.1.0", "@types/node": "^22.20.0", "@types/react": "^19.2.17", 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 new file mode 100644 index 0000000..c830dc1 --- /dev/null +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -0,0 +1,651 @@ +"use client"; +"use no memo"; + +import * as React from "react"; +import { + Background, + BackgroundVariant, + Controls, + type Edge as RFEdge, + Handle, + MiniMap, + type Node as RFNode, + type NodeProps, + Panel, + Position, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, +} from "d3-force"; +import { + AppWindow, + Building2, + Globe, + Link2, + type LucideIcon, + MoreHorizontal, + Radio, + Server, + Waypoints, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { api } from "@/lib/api"; +import type { CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; + +// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。 +const FOLD_LIMIT = 20; +const FOLD_STEP = 20; + +type Kind = CoverageGraphNode["kind"]; + +type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string }; + +const kindMeta: Record = { + company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b" }, + root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1" }, + subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6" }, + ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2" }, + service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b" }, + app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef" }, + endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e" }, +}; + +// --------------------------------------------------------------------------- +// Folding: turn the full graph into the currently-visible node/edge set. +// 折叠是自顶向下的:某节点被折叠隐藏时,它的整棵子树也不再展开。 +// --------------------------------------------------------------------------- + +type FoldNode = { + fold: true; + key: string; + groupId: string; + parentKey: string; + kind: Kind; + hidden: CoverageGraphNode[]; +}; +type AssetRenderNode = { fold: false; key: string; kind: Kind; node: CoverageGraphNode }; +type RenderNode = AssetRenderNode | FoldNode; + +// tested 优先,其次按 label。 +function sortChildren(a: CoverageGraphNode, b: CoverageGraphNode): number { + if (a.tested !== b.tested) return a.tested ? -1 : 1; + return (a.label || "").localeCompare(b.label || ""); +} + +function computeVisible( + nodes: CoverageGraphNode[], + edges: CoverageGraphEdge[], + expanded: Map, +): { renderNodes: RenderNode[]; renderEdges: CoverageGraphEdge[] } { + const byKey = new Map(nodes.map((n) => [n.key, n])); + const parentOf = new Map(); + const childrenOf = new Map(); + for (const e of edges) { + if (!byKey.has(e.src) || !byKey.has(e.dst)) continue; + if (!parentOf.has(e.src)) parentOf.set(e.src, e.dst); + const arr = childrenOf.get(e.dst); + if (arr) arr.push(e.src); + else childrenOf.set(e.dst, [e.src]); + } + + const renderNodes: RenderNode[] = []; + const visible = new Set(); + const foldNodes: FoldNode[] = []; + + // 顶层:没有父节点的节点(公司 / 孤立根域名 / 孤立 ip…)。 + const queue: string[] = []; + for (const n of nodes) { + if (!parentOf.has(n.key)) { + queue.push(n.key); + visible.add(n.key); + } + } + + for (let qi = 0; qi < queue.length; qi++) { + const parent = queue[qi]; + const kids = childrenOf.get(parent) ?? []; + if (kids.length === 0) continue; + // 按类型分组 + const groups = new Map(); + for (const ck of kids) { + const cn = byKey.get(ck); + if (!cn) continue; + const g = groups.get(cn.kind); + if (g) g.push(cn); + else groups.set(cn.kind, [cn]); + } + for (const [kind, arr] of groups) { + arr.sort(sortChildren); + const groupId = `${parent}::${kind}`; + const shown = expanded.get(groupId) ?? FOLD_LIMIT; + const visibleChildren = arr.slice(0, shown); + const hidden = arr.slice(shown); + for (const c of visibleChildren) { + if (!visible.has(c.key)) { + visible.add(c.key); + queue.push(c.key); + } + } + if (hidden.length > 0) { + foldNodes.push({ + fold: true, + key: `fold:${groupId}`, + groupId, + parentKey: parent, + kind, + hidden, + }); + } + } + } + + for (const n of nodes) { + if (visible.has(n.key)) renderNodes.push({ fold: false, key: n.key, kind: n.kind, node: n }); + } + const renderEdges: CoverageGraphEdge[] = edges.filter( + (e) => visible.has(e.src) && visible.has(e.dst), + ); + for (const f of foldNodes) { + renderNodes.push(f); + renderEdges.push({ src: f.key, dst: f.parentKey }); + } + return { renderNodes, renderEdges }; +} + +// --------------------------------------------------------------------------- +// d3-force 静态布局:一次性 tick 到收敛,得到每个节点坐标。 +// --------------------------------------------------------------------------- +function layout( + renderNodes: RenderNode[], + renderEdges: CoverageGraphEdge[], +): Map { + const sim = renderNodes.map((n) => ({ key: n.key })) as Array<{ + key: string; + x?: number; + y?: number; + }>; + const links = renderEdges.map((e) => ({ source: e.src, target: e.dst })); + const s = forceSimulation(sim) + .force("charge", forceManyBody().strength(-420)) + .force( + "link", + forceLink(links) + .id((d) => (d as unknown as { key: string }).key) + .distance(96) + .strength(0.55), + ) + .force("center", forceCenter(0, 0)) + .force("x", forceX(0).strength(0.05)) + .force("y", forceY(0).strength(0.05)) + .force("collide", forceCollide(52)) + .stop(); + const ticks = Math.min(400, 120 + sim.length * 2); + for (let i = 0; i < ticks; i++) s.tick(); + const pos = new Map(); + for (const d of sim) pos.set(d.key, { x: d.x ?? 0, y: d.y ?? 0 }); + return pos; +} + +// --------------------------------------------------------------------------- +// 节点视觉:已测=实色高亮;范围内未测=灰;范围外连接节点=更淡的灰 + 虚线。 +// --------------------------------------------------------------------------- +function nodeTone(n: CoverageGraphNode): "tested" | "untested" | "context" { + if (!n.in_scope) return "context"; + return n.tested ? "tested" : "untested"; +} + +type AssetNodeData = { rn: AssetRenderNode }; +type FoldNodeData = { rn: FoldNode }; +type AssetRF = RFNode; +type FoldRF = RFNode; + +function AssetGraphNode({ data, selected }: NodeProps) { + const n = data.rn.node; + const meta = kindMeta[n.kind]; + const Icon = meta.icon; + const tone = nodeTone(n); + + return ( +
+
+ + + + +
+
+ {n.label || meta.label} +
+
{meta.label}
+
+ {tone === "tested" && ( + + )} + +
+
+ ); +} + +function FoldGraphNode({ data, selected }: NodeProps) { + const f = data.rn; + const meta = kindMeta[f.kind]; + return ( +
+
+ + + + 还有 {f.hidden.length} 个{meta.label} + +
+
+ ); +} + +const nodeTypes = { asset: AssetGraphNode, fold: FoldGraphNode }; + +// --------------------------------------------------------------------------- +// 抽屉:资产节点看详情;折叠节点看隐藏列表 + "展示更多"。 +// --------------------------------------------------------------------------- +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + if (children === undefined || children === null || children === "") return null; + return ( +
+ {label} + {children} +
+ ); +} + +function AssetSheet({ + node, + onOpenChange, +}: { + node: CoverageGraphNode | null; + onOpenChange: (open: boolean) => void; +}) { + const meta = node ? kindMeta[node.kind] : null; + const Icon = meta?.icon; + const raw = node ? JSON.stringify(node, null, 2) : ""; + return ( + + + {node && meta && Icon && ( + <> + +
+ + + +
+ {meta.label} + + {node.label} + +
+
+
+ +
+
+

属性

+ {meta.label} + + {node.in_scope ? ( + node.tested ? ( + 已测试 + ) : ( + 未测试 + ) + ) : ( + 范围外(连接节点) + )} + + {node.domain} + {node.root_domain} + {node.ip} + {node.port ? node.port : undefined} + + {node.url ? ( + {node.url} + ) : undefined} + + {node.page_title} + {node.status_code ? node.status_code : undefined} + {node.app_name} + + {node.asset_id ? {node.asset_id} : undefined} + +
+
+

原始数据

+
+                    {raw}
+                  
+
+
+
+ + )} +
+
+ ); +} + +function FoldSheet({ + fold, + onOpenChange, + onShowMore, + onPick, +}: { + fold: FoldNode | null; + onOpenChange: (open: boolean) => void; + onShowMore: (groupId: string) => void; + onPick: (n: CoverageGraphNode) => void; +}) { + const meta = fold ? kindMeta[fold.kind] : null; + const Icon = meta?.icon; + return ( + + + {fold && meta && Icon && ( + <> + + + 未展示的{meta.label}({fold.hidden.length}) + +

+ 已测优先展示。点「展示更多」把下一批拉进图里。 +

+
+ +
+ {fold.hidden.map((n) => ( + + ))} +
+
+
+ +
+ + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +function GraphInner({ taskId }: { taskId: string }) { + const [data, setData] = React.useState<{ + nodes: CoverageGraphNode[]; + edges: CoverageGraphEdge[]; + } | null>(null); + const [expanded, setExpanded] = React.useState>(new Map()); + const [selectedAsset, setSelectedAsset] = React.useState(null); + const [selectedFoldId, setSelectedFoldId] = React.useState(null); + + const [rfNodes, setRfNodes, onNodesChange] = useNodesState([]); + const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState([]); + + React.useEffect(() => { + let cancelled = false; + const load = () => { + api + .taskCoverageGraph(taskId) + .then((g) => { + if (cancelled) return; + setData({ nodes: g.nodes ?? [], edges: g.edges ?? [] }); + }) + .catch(() => { + /* 保留上一次数据 */ + }); + }; + load(); + const timer = setInterval(load, 8000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [taskId]); + + const { renderNodes, renderEdges } = React.useMemo( + () => + data + ? computeVisible(data.nodes, data.edges, expanded) + : { renderNodes: [] as RenderNode[], renderEdges: [] as CoverageGraphEdge[] }, + [data, expanded], + ); + + // 布局只在「可见节点集合」变化时重算,避免轮询导致抖动。 + const sig = React.useMemo( + () => `${renderNodes.map((n) => n.key).sort().join(",")}|${renderEdges.length}`, + [renderNodes, renderEdges], + ); + const posRef = React.useRef>(new Map()); + const lastSig = React.useRef(""); + if (sig !== lastSig.current) { + posRef.current = layout(renderNodes, renderEdges); + lastSig.current = sig; + } + + React.useEffect(() => { + const pos = posRef.current; + setRfNodes( + renderNodes.map((rn) => { + const p = pos.get(rn.key) ?? { x: 0, y: 0 }; + return rn.fold + ? ({ id: rn.key, type: "fold", position: p, data: { rn } } as FoldRF) + : ({ id: rn.key, type: "asset", position: p, data: { rn } } as AssetRF); + }), + ); + setRfEdges( + renderEdges.map((e, i) => ({ + id: `e${i}-${e.src}-${e.dst}`, + source: e.src, + target: e.dst, + type: "default", + style: { stroke: "#cbd5e1", strokeWidth: 1.5 }, + })), + ); + }, [renderNodes, renderEdges, setRfNodes, setRfEdges]); + + const showMore = React.useCallback((groupId: string) => { + setExpanded((prev) => { + const m = new Map(prev); + m.set(groupId, (m.get(groupId) ?? FOLD_LIMIT) + FOLD_STEP); + return m; + }); + }, []); + + const selectedFold = + selectedFoldId != null + ? (renderNodes.find((n) => n.fold && n.key === selectedFoldId) as FoldNode | undefined) ?? null + : null; + // 折叠节点全部展开后自动关闭抽屉。 + React.useEffect(() => { + if (selectedFoldId != null && !selectedFold) setSelectedFoldId(null); + }, [selectedFoldId, selectedFold]); + + const total = data?.nodes.length ?? 0; + const tested = data?.nodes.filter((n) => n.in_scope && n.tested).length ?? 0; + const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0; + + return ( + <> + { + const rn = (node.data as AssetNodeData | FoldNodeData).rn; + if (rn.fold) setSelectedFoldId(rn.key); + else setSelectedAsset(rn.node); + }} + nodeTypes={nodeTypes} + fitView + fitViewOptions={{ padding: 0.2 }} + proOptions={{ hideAttribution: true }} + minZoom={0.05} + className="!bg-[#f0f2f7] dark:!bg-neutral-950" + > + + + { + const rn = (node.data as AssetNodeData | FoldNodeData | undefined)?.rn; + if (!rn || rn.fold) return "#94a3b8"; + return rn.node.in_scope && rn.node.tested ? kindMeta[rn.node.kind].hex : "#cbd5e1"; + }} + nodeStrokeWidth={0} + nodeBorderRadius={4} + /> + +
+ {total === 0 && 暂无范围内资产(先锚定任务范围)} + {total > 0 && ( +
+ 范围内 {inScope} · 已测{" "} + {tested} +
+ )} +
+ {(Object.keys(kindMeta) as Kind[]).map((k) => { + const m = kindMeta[k]; + const Icon = m.icon; + return ( + + + + + {m.label} + + ); + })} +
+
+ + 已测(高亮) + + + 未测 + + + 范围外 + +
+
+
+
+ !o && setSelectedAsset(null)} /> + !o && setSelectedFoldId(null)} + onShowMore={showMore} + onPick={(n) => { + setSelectedFoldId(null); + setSelectedAsset(n); + }} + /> + + ); +} + +export function CoverageGraphTab({ taskId }: { taskId: string }) { + return ( + + +
+ + + +
+
+
+ ); +} diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index b738565..ac520e6 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -26,6 +26,7 @@ import { FindingsTab } from "./_tabs/findings-tab"; import { ReportTab } from "./_tabs/report-tab"; import { InterceptTab } from "./_tabs/intercept-tab"; import { AssetsTab } from "./_tabs/assets-tab"; +import { CoverageGraphTab } from "./_tabs/coverage-graph-tab"; const TABS = [ { value: "sessions", label: "会话" }, @@ -33,6 +34,7 @@ const TABS = [ { value: "graph", label: "探索链路" }, { value: "findings", label: "发现" }, { value: "assets", label: "测试资产" }, + { value: "coverage", label: "资产覆盖图" }, { value: "intercept", label: "拦截审批" }, { value: "report", label: "报告" }, ]; @@ -179,6 +181,9 @@ function TaskDetailInner() { + + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b02f7e8..21e198f 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, + TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CommandRecord, LLMRecordItem, LLMRecordDetail, } from "@/lib/types"; @@ -124,6 +124,9 @@ export const api = { pct: number | null; by_type: { type: string; total: number; tested: number }[]; }>(`/tasks/${id}/coverage`), + // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 + taskCoverageGraph: (id: string) => + get(`/tasks/${id}/coverage-graph`), // ---- 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 525724f..f4f299d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -133,6 +133,38 @@ export interface Asset { last_seen: string; } +// ---- Asset coverage graph (per task) ---- +// 力导向「资产覆盖图」的一个节点。key 唯一:资产="a:"、公司="c:"、 +// 无资产行的根域名="r:"。in_scope=false 的是仅用于连线的灰色上下文节点。 +export interface CoverageGraphNode { + key: string; + kind: "company" | "root_domain" | "subdomain" | "ip" | "service" | "app" | "endpoint"; + label: string; + tested: boolean; + in_scope: boolean; + asset_id?: number; + company_id?: number; + domain?: string; + root_domain?: string; + ip?: string; + url?: string; + port?: number; + service_type?: string; + app_name?: string; + page_title?: string; + status_code?: number; +} + +export interface CoverageGraphEdge { + src: string; + dst: string; +} + +export interface CoverageGraphData { + nodes: CoverageGraphNode[]; + edges: CoverageGraphEdge[]; +} + // 公司资产范围规则的一条(归属唯一真值来源)。 export interface ScopeRow { id: number;