feat(coverage): 任务新增「资产覆盖图」力导向图 tab

后端(只读,无迁移):
- 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 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-08 12:30:41 -04:00
1 parent 4c4f65f40a
commit c396674d2b
8 files changed
+970 -5

No files matched your search

+248
View File
@@ -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:<id>", a company is "c:<id>", and a root domain
// with no asset row of its own is the synthetic "r:<domain>". 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:<domain>" 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) {
+23
View File
@@ -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"))
+5 -4
View File
@@ -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",
+2
View File
@@ -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",
@@ -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<Kind, KindMeta> = {
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<string, number>,
): { renderNodes: RenderNode[]; renderEdges: CoverageGraphEdge[] } {
const byKey = new Map(nodes.map((n) => [n.key, n]));
const parentOf = new Map<string, string>();
const childrenOf = new Map<string, string[]>();
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<string>();
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<Kind, CoverageGraphNode[]>();
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<string, { x: number; y: number }> {
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<string, { x: number; y: number }>();
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<AssetNodeData, "asset">;
type FoldRF = RFNode<FoldNodeData, "fold">;
function AssetGraphNode({ data, selected }: NodeProps<AssetRF>) {
const n = data.rn.node;
const meta = kindMeta[n.kind];
const Icon = meta.icon;
const tone = nodeTone(n);
return (
<div
className={cn(
"w-[180px] cursor-pointer rounded-xl border-2 transition-colors",
selected
? "border-blue-500"
: tone === "tested"
? "border-emerald-400/70"
: "border-transparent",
)}
>
<div
className={cn(
"group relative flex items-center gap-2 rounded-[10px] border px-2.5 py-2 shadow-sm transition-shadow hover:shadow-md",
tone === "context"
? "border-dashed border-neutral-300 bg-neutral-100/70 dark:border-neutral-700 dark:bg-neutral-900/60"
: "border-black/[0.06] bg-white dark:border-white/10 dark:bg-neutral-900",
tone === "untested" && "opacity-90",
)}
>
<Handle type="target" position={Position.Top} className="!size-1.5 !border-0 !bg-neutral-300 opacity-0" />
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm",
tone === "tested" ? meta.iconBg : "bg-neutral-400 dark:bg-neutral-600",
)}
>
<Icon className="size-3.5 text-white" />
</span>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-[12px] font-semibold",
tone === "tested"
? "text-neutral-800 dark:text-neutral-100"
: "text-neutral-500 dark:text-neutral-400",
)}
title={n.label}
>
{n.label || meta.label}
</div>
<div className="text-[10px] text-neutral-400">{meta.label}</div>
</div>
{tone === "tested" && (
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500" title="已测试" />
)}
<Handle type="source" position={Position.Bottom} className="!size-1.5 !border-0 !bg-neutral-300 opacity-0" />
</div>
</div>
);
}
function FoldGraphNode({ data, selected }: NodeProps<FoldRF>) {
const f = data.rn;
const meta = kindMeta[f.kind];
return (
<div
className={cn(
"cursor-pointer rounded-full border-2 transition-colors",
selected ? "border-blue-500" : "border-transparent",
)}
>
<div className="group relative flex items-center gap-1.5 rounded-full border border-dashed border-neutral-400 bg-neutral-50 px-3 py-1.5 shadow-sm hover:bg-neutral-100 dark:border-neutral-600 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Handle type="target" position={Position.Top} className="!size-1.5 !border-0 !bg-neutral-300 opacity-0" />
<MoreHorizontal className="size-4 text-neutral-500" />
<span className="text-[11px] font-medium text-neutral-600 dark:text-neutral-300">
还有 {f.hidden.length} 个{meta.label}
</span>
</div>
</div>
);
}
const nodeTypes = { asset: AssetGraphNode, fold: FoldGraphNode };
// ---------------------------------------------------------------------------
// 抽屉:资产节点看详情;折叠节点看隐藏列表 + "展示更多"。
// ---------------------------------------------------------------------------
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
if (children === undefined || children === null || children === "") return null;
return (
<div className="flex items-start gap-3 py-1 text-sm">
<span className="text-muted-foreground w-16 shrink-0">{label}</span>
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
</div>
);
}
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 (
<Sheet open={node !== null} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md">
{node && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<div className="flex items-center gap-2.5 pr-8">
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm", meta.iconBg)}>
<Icon className="size-4 text-white" />
</span>
<div className="min-w-0">
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground truncate font-mono text-xs" title={node.label}>
{node.label}
</span>
</div>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
<section>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">属性</h4>
<DetailRow label="类型">{meta.label}</DetailRow>
<DetailRow label="测试状态">
{node.in_scope ? (
node.tested ? (
<span className="text-emerald-600 dark:text-emerald-400">已测试</span>
) : (
<span className="text-neutral-500">未测试</span>
)
) : (
<span className="text-neutral-400">范围外(连接节点)</span>
)}
</DetailRow>
<DetailRow label="域名">{node.domain}</DetailRow>
<DetailRow label="根域名">{node.root_domain}</DetailRow>
<DetailRow label="IP">{node.ip}</DetailRow>
<DetailRow label="端口">{node.port ? node.port : undefined}</DetailRow>
<DetailRow label="URL">
{node.url ? (
<span className="font-mono text-xs break-all">{node.url}</span>
) : undefined}
</DetailRow>
<DetailRow label="标题">{node.page_title}</DetailRow>
<DetailRow label="状态码">{node.status_code ? node.status_code : undefined}</DetailRow>
<DetailRow label="App">{node.app_name}</DetailRow>
<DetailRow label="资产ID">
{node.asset_id ? <span className="font-mono text-xs">{node.asset_id}</span> : undefined}
</DetailRow>
</section>
<section className="border-t pt-3">
<h4 className="text-muted-foreground mb-1.5 text-xs font-medium">原始数据</h4>
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
{raw}
</pre>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
);
}
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 (
<Sheet open={fold !== null} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md">
{fold && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<SheetTitle className="text-base">
未展示的{meta.label}({fold.hidden.length})
</SheetTitle>
<p className="text-muted-foreground text-xs">
已测优先展示。点「展示更多」把下一批拉进图里。
</p>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-1 p-3">
{fold.hidden.map((n) => (
<button
key={n.key}
type="button"
onClick={() => onPick(n)}
className="hover:bg-accent flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
>
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded",
n.tested ? meta.iconBg : "bg-neutral-400 dark:bg-neutral-600",
)}
>
<Icon className="size-3 text-white" />
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={n.label}>
{n.label}
</span>
{n.tested && <span className="size-1.5 shrink-0 rounded-full bg-emerald-500" />}
</button>
))}
</div>
</ScrollArea>
<div className="border-t p-3">
<Button className="w-full" variant="outline" onClick={() => onShowMore(fold.groupId)}>
展示更多(+{FOLD_STEP})
</Button>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
// ---------------------------------------------------------------------------
function GraphInner({ taskId }: { taskId: string }) {
const [data, setData] = React.useState<{
nodes: CoverageGraphNode[];
edges: CoverageGraphEdge[];
} | null>(null);
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 [rfNodes, setRfNodes, onNodesChange] = useNodesState<AssetRF | FoldRF>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
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<Map<string, { x: number; y: number }>>(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 (
<>
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => {
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"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} className="text-neutral-400/50 dark:text-neutral-700/60" />
<Controls
showInteractive={false}
className="!rounded-lg !border !shadow-sm [&>button]:!border-border [&>button]:!bg-card [&>button:hover]:!bg-accent [&_svg]:!fill-foreground"
/>
<MiniMap
pannable
zoomable
className="!bg-card !rounded-lg !border !shadow-sm"
maskColor="rgb(148 163 184 / 0.18)"
nodeColor={(node) => {
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}
/>
<Panel position="top-left">
<div className="bg-card/95 flex flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
{total === 0 && <span className="text-muted-foreground">暂无范围内资产(先锚定任务范围)</span>}
{total > 0 && (
<div className="text-muted-foreground">
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span> · 已测{" "}
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
</div>
)}
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{(Object.keys(kindMeta) as Kind[]).map((k) => {
const m = kindMeta[k];
const Icon = m.icon;
return (
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
<Icon className="size-2.5 text-white" />
</span>
{m.label}
</span>
);
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded bg-emerald-500" /> 已测(高亮)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded bg-neutral-400" /> 未测
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded border border-dashed border-neutral-400 bg-neutral-100" /> 范围外
</span>
</div>
</div>
</Panel>
</ReactFlow>
<AssetSheet node={selectedAsset} onOpenChange={(o) => !o && setSelectedAsset(null)} />
<FoldSheet
fold={selectedFold}
onOpenChange={(o) => !o && setSelectedFoldId(null)}
onShowMore={showMore}
onPick={(n) => {
setSelectedFoldId(null);
setSelectedAsset(n);
}}
/>
</>
);
}
export function CoverageGraphTab({ taskId }: { taskId: string }) {
return (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<ReactFlowProvider>
<GraphInner taskId={taskId} />
</ReactFlowProvider>
</div>
</CardContent>
</Card>
);
}
@@ -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() {
<TabsContent value="assets" className="mt-0">
<AssetsTab taskId={id} />
</TabsContent>
<TabsContent value="coverage" className="mt-0">
<CoverageGraphTab taskId={id} />
</TabsContent>
<TabsContent value="intercept" className="mt-0">
<InterceptTab taskId={id} />
</TabsContent>
+4 -1
View File
@@ -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<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
// ---- assets ----
// Server-side paginated: pass limit/offset, get back the page + full match total.
+32
View File
@@ -133,6 +133,38 @@ export interface Asset {
last_seen: string;
}
// ---- Asset coverage graph (per task) ----
// 力导向「资产覆盖图」的一个节点。key 唯一:资产="a:<id>"、公司="c:<id>"、
// 无资产行的根域名="r:<domain>"。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;