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:
Autumn-27andClaude Opus 4.8 committed 2026-08-09 00:28:19 -04:00
1 parent 9c1591ad8e
commit e35c766e6a
5 files changed
+218 -14

No files matched your search

+45
View File
@@ -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
+35
View File
@@ -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
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, 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.
+13
View File
@@ -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;