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:
1 parent
4c4f65f40a
commit
c396674d2b
8 files changed
+970
-5
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Generated
+5
-4
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user