feat(coverage): 覆盖图节点用 lucide 图标 + 精简 label + 抽屉展示关联意图/事实
- 节点图标 emoji → 平台一致的 lucide 图标(白色 SVG data URI,iconSrc)
- 图内 label:服务显示 端口·标题·状态码(不再完整 URL),端点只显示 path
- 点节点抽屉新增「关联意图/关联事实/关联发现」:按 exploration_anchors
反查本任务锚定该资产的节点
- db AssetRefs(assetID);server GET /api/tasks/{id}/asset-refs?asset_id=X
- types/api taskAssetRefs;抽屉按 node.asset_id 拉取渲染
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
9c1591ad8e
commit
e35c766e6a
5 files changed
+218
-14
No files matched your search
@@ -615,6 +615,51 @@ AND (summary ILIKE $2 OR detail ILIKE $2) ORDER BY id LIMIT $3`, s.expID, like,
|
||||
return scanTrace(rows)
|
||||
}
|
||||
|
||||
// AssetRef is a compact exploration node (intent/fact/finding) that references an
|
||||
// asset via an anchor — for the coverage graph's node drawer.
|
||||
type AssetRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// AssetRefs returns the intents / facts / findings in THIS exploration anchored to
|
||||
// the given asset id (newest first) — what this task tested / concluded about it.
|
||||
func (s *ExplorationStore) AssetRefs(assetID int64) ([]AssetRef, error) {
|
||||
if assetID <= 0 {
|
||||
return []AssetRef{}, nil
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT en.id, en.kind, en.state, en.payload
|
||||
FROM exploration_anchors ea
|
||||
JOIN exploration_nodes en ON en.id = ea.node_id
|
||||
WHERE ea.asset_id = $1 AND en.exploration_id = $2 AND en.kind IN ('intent','fact','finding')
|
||||
ORDER BY en.id DESC`, assetID, s.expID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []AssetRef{}
|
||||
for rows.Next() {
|
||||
var r AssetRef
|
||||
var payload []byte
|
||||
if err := rows.Scan(&r.ID, &r.Kind, &r.State, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(payload, &p)
|
||||
for _, k := range []string{"summary", "text"} {
|
||||
if v, ok := p[k].(string); ok && strings.TrimSpace(v) != "" {
|
||||
r.Summary = v
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ActivityTraceSearchExcluding keyword-searches every node's steps in this
|
||||
// exploration EXCEPT one node's own (excludeNodeID) — so a worker's
|
||||
// search_all_worker_traces doesn't return its own in-progress trace (which is
|
||||
|
||||
@@ -484,6 +484,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
||||
mux.HandleFunc("POST /api/active", s.setActive)
|
||||
@@ -1130,6 +1131,40 @@ func (s *Server) taskCoverageGraph(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, g)
|
||||
}
|
||||
|
||||
// taskAssetRefs returns the intents / facts / findings in this task anchored to a
|
||||
// given asset id — powers the coverage-graph node drawer's「关联意图 / 关联事实」。
|
||||
func (s *Server) taskAssetRefs(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return
|
||||
}
|
||||
assetID, _ := strconv.ParseInt(r.URL.Query().Get("asset_id"), 10, 64)
|
||||
if assetID <= 0 {
|
||||
writeErr(w, 400, "需要 asset_id")
|
||||
return
|
||||
}
|
||||
refs, err := t.Store.AssetRefs(assetID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
intents := []db.AssetRef{}
|
||||
facts := []db.AssetRef{}
|
||||
findings := []db.AssetRef{}
|
||||
for _, ref := range refs {
|
||||
switch ref.Kind {
|
||||
case "intent":
|
||||
intents = append(intents, ref)
|
||||
case "fact":
|
||||
facts = append(facts, ref)
|
||||
case "finding":
|
||||
findings = append(findings, ref)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"intents": intents, "facts": facts, "findings": findings})
|
||||
}
|
||||
|
||||
// taskScopeList returns a task's scope rows (coverage denominator sources).
|
||||
func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
|
||||
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
|
||||
|
||||
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
|
||||
const FOLD_LIMIT = 20;
|
||||
@@ -33,18 +33,58 @@ const FOLD_STEP = 20;
|
||||
|
||||
type Kind = CoverageGraphNode["kind"];
|
||||
|
||||
type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; emoji: string; size: number };
|
||||
type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number };
|
||||
|
||||
const kindMeta: Record<Kind, KindMeta> = {
|
||||
company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", emoji: "🏢", size: 46 },
|
||||
root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", emoji: "🌐", size: 38 },
|
||||
subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", emoji: "🔗", size: 30 },
|
||||
ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", emoji: "🖥️", size: 28 },
|
||||
service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", emoji: "📡", size: 26 },
|
||||
app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", emoji: "📱", size: 26 },
|
||||
endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", emoji: "🔌", size: 20 },
|
||||
company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", size: 46 },
|
||||
root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", size: 38 },
|
||||
subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", size: 30 },
|
||||
ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", size: 28 },
|
||||
service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", size: 26 },
|
||||
app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", size: 26 },
|
||||
endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", size: 20 },
|
||||
};
|
||||
|
||||
// G6 节点图标用平台一致的 lucide 图标:把 lucide 的 SVG 路径(v1.22)渲染成白色描边的
|
||||
// data URI,作为节点 iconSrc(白色在实色/灰色底上都清晰)。手写内嵌,避免 react-dom/server
|
||||
// 在 React19/Next 客户端打包的问题。
|
||||
function svgUri(inner: string, filled = false): string {
|
||||
const attrs = filled
|
||||
? 'fill="#fff" stroke="none"'
|
||||
: 'fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ${attrs}>${inner}</svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<Kind, string> = {
|
||||
company: svgUri(
|
||||
'<path d="M10 12h4"/><path d="M10 8h4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/><path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
|
||||
),
|
||||
root_domain: svgUri(
|
||||
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
),
|
||||
subdomain: svgUri(
|
||||
'<path d="m10.586 5.414-5.172 5.172"/><path d="m18.586 13.414-5.172 5.172"/><path d="M6 12h12"/><circle cx="12" cy="20" r="2"/><circle cx="12" cy="4" r="2"/><circle cx="20" cy="12" r="2"/><circle cx="4" cy="12" r="2"/>',
|
||||
),
|
||||
ip: svgUri(
|
||||
'<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
|
||||
),
|
||||
service: svgUri(
|
||||
'<path d="M16.247 7.761a6 6 0 0 1 0 8.478"/><path d="M19.075 4.933a10 10 0 0 1 0 14.134"/><path d="M4.925 19.067a10 10 0 0 1 0-14.134"/><path d="M7.753 16.239a6 6 0 0 1 0-8.478"/><circle cx="12" cy="12" r="2"/>',
|
||||
),
|
||||
app: svgUri(
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/>',
|
||||
),
|
||||
endpoint: svgUri(
|
||||
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
|
||||
),
|
||||
};
|
||||
|
||||
const FOLD_ICON = svgUri(
|
||||
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
|
||||
true,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folding: full graph → currently-visible node/edge set (自顶向下级联折叠)。
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,6 +190,26 @@ function trunc(s: string, n = 26): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
|
||||
// 图里的节点文案:服务不显示完整 URL,只显示 端口·标题·状态码;端点只显示 path。
|
||||
// 其余类型沿用后端给的 label。
|
||||
function graphLabel(n: CoverageGraphNode): string {
|
||||
if (n.kind === "service") {
|
||||
const parts: string[] = [];
|
||||
if (n.port) parts.push(`:${n.port}`);
|
||||
if (n.page_title) parts.push(n.page_title);
|
||||
if (n.status_code) parts.push(String(n.status_code));
|
||||
return parts.length ? parts.join(" · ") : n.domain || n.ip || n.label;
|
||||
}
|
||||
if (n.kind === "endpoint" && n.url) {
|
||||
try {
|
||||
return new URL(n.url).pathname || "/";
|
||||
} catch {
|
||||
/* 非法 URL:回退到完整 label */
|
||||
}
|
||||
}
|
||||
return n.label;
|
||||
}
|
||||
|
||||
function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
return renderNodes.map((rn) => {
|
||||
if (rn.fold) {
|
||||
@@ -169,7 +229,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
fold: false,
|
||||
tested: rn.node.tested,
|
||||
inScope: rn.node.in_scope,
|
||||
lbl: trunc(rn.node.label || kindMeta[rn.kind].label),
|
||||
lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
|
||||
size: kindMeta[rn.kind].size,
|
||||
};
|
||||
});
|
||||
@@ -205,16 +265,56 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
|
||||
);
|
||||
}
|
||||
|
||||
function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{title}({items.length})
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((r) => (
|
||||
<div key={`${r.kind}-${r.id}`} className="bg-muted/50 flex items-start gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
<span className="text-muted-foreground shrink-0 font-mono">#{r.id}</span>
|
||||
{r.state && <span className="text-muted-foreground shrink-0">{r.state}</span>}
|
||||
<span className="text-foreground min-w-0 flex-1 break-words">{r.summary || "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetSheet({
|
||||
node,
|
||||
taskId,
|
||||
onOpenChange,
|
||||
}: {
|
||||
node: CoverageGraphNode | null;
|
||||
taskId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const meta = node ? kindMeta[node.kind] : null;
|
||||
const Icon = meta?.icon;
|
||||
const raw = node ? JSON.stringify(node, null, 2) : "";
|
||||
const [refs, setRefs] = React.useState<CoverageAssetRefs | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRefs(null);
|
||||
if (!node?.asset_id) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.taskAssetRefs(taskId, node.asset_id)
|
||||
.then((r) => {
|
||||
if (!cancelled) setRefs(r);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 无关联或出错:不展示该区块 */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [node, taskId]);
|
||||
return (
|
||||
<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">
|
||||
@@ -263,6 +363,13 @@ function AssetSheet({
|
||||
{node.asset_id ? <span className="font-mono text-xs">{node.asset_id}</span> : undefined}
|
||||
</DetailRow>
|
||||
</section>
|
||||
{refs && (refs.intents.length > 0 || refs.facts.length > 0 || refs.findings.length > 0) && (
|
||||
<section className="flex flex-col gap-3 border-t pt-3">
|
||||
<RefList title="关联意图" items={refs.intents} />
|
||||
<RefList title="关联事实" items={refs.facts} />
|
||||
<RefList title="关联发现" items={refs.findings} />
|
||||
</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">
|
||||
@@ -424,8 +531,9 @@ function GraphInner({ taskId }: { taskId: string }) {
|
||||
stroke: (d: unknown) => nodeStroke(nd(d)),
|
||||
lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5),
|
||||
lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]),
|
||||
iconText: (d: unknown) => (nd(d).fold ? "⋯" : kindMeta[nd(d).kind].emoji),
|
||||
iconFontSize: (d: unknown) => Math.max(11, nd(d).size * 0.48),
|
||||
iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
|
||||
iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55),
|
||||
iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55),
|
||||
labelText: (d: unknown) => nd(d).lbl,
|
||||
labelFontSize: 10,
|
||||
labelPlacement: "bottom",
|
||||
@@ -551,7 +659,7 @@ function GraphInner({ taskId }: { taskId: string }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AssetSheet node={selectedAsset} onOpenChange={(o) => !o && setSelectedAsset(null)} />
|
||||
<AssetSheet node={selectedAsset} taskId={taskId} onOpenChange={(o) => !o && setSelectedAsset(null)} />
|
||||
<FoldSheet
|
||||
fold={selectedFold}
|
||||
onOpenChange={(o) => !o && setSelectedFoldId(null)}
|
||||
|
||||
+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, CoverageGraphData,
|
||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||
CommandRecord, LLMRecordItem, LLMRecordDetail,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -127,6 +127,9 @@ export const api = {
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) =>
|
||||
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
||||
taskAssetRefs: (id: string, assetId: number) =>
|
||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
|
||||
// ---- assets ----
|
||||
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
||||
|
||||
@@ -165,6 +165,19 @@ export interface CoverageGraphData {
|
||||
edges: CoverageGraphEdge[];
|
||||
}
|
||||
|
||||
// 某资产在本任务探索图里关联到的意图/事实/发现(覆盖图节点抽屉用)。
|
||||
export interface CoverageAssetRef {
|
||||
id: number;
|
||||
kind: string;
|
||||
state: string;
|
||||
summary: string;
|
||||
}
|
||||
export interface CoverageAssetRefs {
|
||||
intents: CoverageAssetRef[];
|
||||
facts: CoverageAssetRef[];
|
||||
findings: CoverageAssetRef[];
|
||||
}
|
||||
|
||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||
export interface ScopeRow {
|
||||
id: number;
|
||||
|
||||
Reference in new issue
Block a user