refactor: 抽出共享 ExplorationGraph 画布,漏洞链路图改用 React Flow
- 新增 components/exploration-graph.tsx:把任务图谱 Tab 的 React Flow 渲染
(节点卡片/自动分层布局/连线/小地图/图例/点击抽屉)整体抽成 <ExplorationGraph
nodes edges> 复用组件,取 {nodes,edges} 作为 props
- graph-tab.tsx 精简为纯取数(轮询 explorationGraph)+ 复用该画布
- 漏洞详情「链路图」Tab 改用同一画布渲染 lineage 子图(从初始节点到漏洞节点),
与任务攻击链路图完全一致的观感;之前的纵向卡片版删除
纯前端重构,后端 lineage 接口不变。tsc 通过(tasks/page.tsx 的既有报错与本次无关)。
This commit is contained in:
1 parent
b741ee176c
commit
fcc1e75a85
4 files changed
+543
-786
No files matched your search
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
type Edge as RFEdge,
|
||||
Handle,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type Node as RFNode,
|
||||
type NodeProps,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Bug,
|
||||
Compass,
|
||||
Flag,
|
||||
FlaskConical,
|
||||
Lightbulb,
|
||||
type LucideIcon,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { toneClasses, type Tone } from "@/lib/status";
|
||||
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
|
||||
|
||||
export const relLabel: Record<string, string> = {
|
||||
spawns: "派生",
|
||||
derived_from: "意图链",
|
||||
yields: "产出",
|
||||
proves: "证明",
|
||||
};
|
||||
|
||||
export const relColor: Record<string, string> = {
|
||||
spawns: "#10b981",
|
||||
derived_from: "#6366f1",
|
||||
yields: "#f59e0b",
|
||||
proves: "#ef4444",
|
||||
};
|
||||
|
||||
type TypeMeta = {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string;
|
||||
chip: string;
|
||||
hex: string;
|
||||
};
|
||||
|
||||
const typeMeta: Record<ExploreKind, TypeMeta> = {
|
||||
task: { label: "根任务", icon: Flag, iconBg: "bg-slate-500", chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", hex: "#64748b" },
|
||||
begin: { label: "起点", icon: Flag, iconBg: "bg-slate-500", chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", hex: "#64748b" },
|
||||
goal: { label: "目标", icon: Target, iconBg: "bg-emerald-500", chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", hex: "#10b981" },
|
||||
hint: { label: "提示", icon: Lightbulb, iconBg: "bg-violet-500", chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400", hex: "#8b5cf6" },
|
||||
intent: { label: "意图", icon: Compass, iconBg: "bg-blue-500", chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400", hex: "#3b82f6" },
|
||||
fact: { label: "事实", icon: FlaskConical, iconBg: "bg-amber-500", chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400", hex: "#f59e0b" },
|
||||
finding: { label: "漏洞", icon: Bug, iconBg: "bg-rose-500", chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400", hex: "#f43f5e" },
|
||||
};
|
||||
|
||||
// The task root is an origin fact (kind='fact', state='origin'); render it as "起点".
|
||||
function viewKind(n: TaskNode): ExploreKind {
|
||||
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
|
||||
}
|
||||
|
||||
function prioTone(p: number): Tone {
|
||||
return p >= 8 ? "red" : p >= 5 ? "amber" : "slate";
|
||||
}
|
||||
|
||||
const summaryFields: Record<ExploreKind, string[]> = {
|
||||
task: ["summary", "description"],
|
||||
begin: ["summary", "description"],
|
||||
goal: ["text"],
|
||||
hint: [],
|
||||
intent: ["summary"],
|
||||
fact: ["summary"],
|
||||
finding: ["name", "summary"],
|
||||
};
|
||||
|
||||
function nodeSummary(n: TaskNode): string {
|
||||
const raw = n.payload ?? "";
|
||||
const fields = summaryFields[viewKind(n)] ?? [];
|
||||
if (fields.length && raw.trim()) {
|
||||
try {
|
||||
const obj: unknown = JSON.parse(raw);
|
||||
if (obj && typeof obj === "object") {
|
||||
for (const f of fields) {
|
||||
const v = (obj as Record<string, unknown>)[f];
|
||||
if (typeof v === "string" && v.trim()) return v;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 非 JSON:原样显示 */
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
const COL_W = 360;
|
||||
const ROW_H = 140;
|
||||
|
||||
type ExploreNodeData = { node: TaskNode };
|
||||
type ExploreRFNode = RFNode<ExploreNodeData, "explore">;
|
||||
|
||||
function pushMap(m: Map<string, string[]>, k: string, v: string) {
|
||||
const a = m.get(k);
|
||||
if (a) a.push(v);
|
||||
else m.set(k, [v]);
|
||||
}
|
||||
|
||||
// computeLayout auto-layers nodes by longest path along edges (cycle-safe DFS
|
||||
// break + barycenter ordering), so origin → … → finding reads left-to-right.
|
||||
function computeLayout(
|
||||
nodes: TaskNode[],
|
||||
edges: Edge[],
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const order = nodes.map((n) => n.id);
|
||||
const idset = new Set(order);
|
||||
const valid = edges.filter((e) => idset.has(e.src) && idset.has(e.dst));
|
||||
const key = (s: string, d: string) => `${s}->${d}`;
|
||||
|
||||
const out = new Map<string, string[]>();
|
||||
for (const e of valid) pushMap(out, e.src, e.dst);
|
||||
|
||||
const seen = new Map<string, 0 | 1 | 2>();
|
||||
const back = new Set<string>();
|
||||
for (const root of order) {
|
||||
if ((seen.get(root) ?? 0) !== 0) continue;
|
||||
const stack: Array<{ u: string; i: number }> = [{ u: root, i: 0 }];
|
||||
seen.set(root, 1);
|
||||
while (stack.length) {
|
||||
const top = stack[stack.length - 1];
|
||||
const nbrs = out.get(top.u) ?? [];
|
||||
if (top.i >= nbrs.length) {
|
||||
seen.set(top.u, 2);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const v = nbrs[top.i++];
|
||||
const s = seen.get(v) ?? 0;
|
||||
if (s === 1) back.add(key(top.u, v));
|
||||
else if (s === 0) {
|
||||
seen.set(v, 1);
|
||||
stack.push({ u: v, i: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dagSucc = new Map<string, string[]>();
|
||||
const indeg = new Map<string, number>(order.map((id) => [id, 0]));
|
||||
for (const e of valid) {
|
||||
if (back.has(key(e.src, e.dst))) continue;
|
||||
pushMap(dagSucc, e.src, e.dst);
|
||||
indeg.set(e.dst, (indeg.get(e.dst) ?? 0) + 1);
|
||||
}
|
||||
const layer = new Map<string, number>(order.map((id) => [id, 0]));
|
||||
const queue = order.filter((id) => (indeg.get(id) ?? 0) === 0);
|
||||
for (let qi = 0; qi < queue.length; qi++) {
|
||||
const u = queue[qi];
|
||||
for (const v of dagSucc.get(u) ?? []) {
|
||||
layer.set(v, Math.max(layer.get(v) ?? 0, (layer.get(u) ?? 0) + 1));
|
||||
const d = (indeg.get(v) ?? 0) - 1;
|
||||
indeg.set(v, d);
|
||||
if (d === 0) queue.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
const maxLayer = Math.max(0, ...layer.values());
|
||||
const columns: string[][] = Array.from({ length: maxLayer + 1 }, () => []);
|
||||
for (const id of order) columns[layer.get(id) ?? 0].push(id);
|
||||
|
||||
const colIndexOf = new Map<string, number>();
|
||||
columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c)));
|
||||
const neighbors = new Map<string, string[]>();
|
||||
for (const e of valid) {
|
||||
const cs = colIndexOf.get(e.src);
|
||||
const cd = colIndexOf.get(e.dst);
|
||||
if (cs === undefined || cd === undefined || Math.abs(cs - cd) !== 1) continue;
|
||||
pushMap(neighbors, e.src, e.dst);
|
||||
pushMap(neighbors, e.dst, e.src);
|
||||
}
|
||||
const sweep = (dir: 1 | -1) => {
|
||||
const start = dir === 1 ? 1 : columns.length - 2;
|
||||
const end = dir === 1 ? columns.length : -1;
|
||||
for (let c = start; c !== end; c += dir) {
|
||||
const ref = c - dir;
|
||||
const refIndex = new Map(columns[ref].map((id, i) => [id, i]));
|
||||
const scored = columns[c].map((id, i) => {
|
||||
const ns = (neighbors.get(id) ?? [])
|
||||
.map((nid) => refIndex.get(nid))
|
||||
.filter((v): v is number => v !== undefined);
|
||||
const bary = ns.length ? ns.reduce((a, b) => a + b, 0) / ns.length : i;
|
||||
return { id, bary, i };
|
||||
});
|
||||
scored.sort((a, b) => a.bary - b.bary || a.i - b.i);
|
||||
columns[c] = scored.map((s) => s.id);
|
||||
}
|
||||
};
|
||||
for (let iter = 0; iter < 4; iter++) {
|
||||
sweep(1);
|
||||
sweep(-1);
|
||||
}
|
||||
|
||||
const maxCount = Math.max(1, ...columns.map((c) => c.length));
|
||||
const midline = ((maxCount - 1) * ROW_H) / 2;
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
columns.forEach((ids, c) => {
|
||||
const startY = midline - ((ids.length - 1) * ROW_H) / 2;
|
||||
ids.forEach((id, i) => pos.set(id, { x: c * COL_W, y: startY + i * ROW_H }));
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Dify-style node card.
|
||||
function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
|
||||
const n = data.node;
|
||||
const kind = viewKind(n);
|
||||
const meta = typeMeta[kind] ?? typeMeta.intent;
|
||||
const Icon = meta.icon;
|
||||
const showState = n.type === "goal" || n.type === "intent";
|
||||
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
|
||||
const isLive = n.type === "intent" && n.state === "running";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[240px] cursor-pointer rounded-2xl border-2 transition-colors",
|
||||
selected ? "border-blue-500" : isLive ? "border-blue-400/70" : "border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="group relative rounded-[14px] border border-black/[0.06] bg-white shadow-sm transition-shadow hover:shadow-lg dark:border-white/10 dark:bg-neutral-900">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5">
|
||||
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm", meta.iconBg)}>
|
||||
<Icon className="size-3.5 text-white" />
|
||||
</span>
|
||||
<span className="grow truncate text-[13px] font-semibold text-neutral-700 dark:text-neutral-100">
|
||||
{meta.label}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span className="relative flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-blue-400 opacity-70" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
{showPriority && (
|
||||
<span className={cn("shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums", toneClasses[prioTone(n.priority)])}>
|
||||
P{n.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="line-clamp-3 px-3 pb-2 text-[13px] leading-snug text-neutral-500 dark:text-neutral-400">
|
||||
{nodeSummary(n) || meta.label}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
|
||||
{showState ? (
|
||||
<StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" />
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span>
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-[10px] text-neutral-400 dark:text-neutral-500">#{n.id}</span>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { explore: ExploreNode };
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1.5 text-sm">
|
||||
<span className="text-muted-foreground w-14 shrink-0">{label}</span>
|
||||
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDetailSheet({
|
||||
node,
|
||||
onOpenChange,
|
||||
}: {
|
||||
node: TaskNode | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const meta = node ? (typeMeta[viewKind(node)] ?? typeMeta.intent) : null;
|
||||
const Icon = meta?.icon;
|
||||
const raw = node ? JSON.stringify(node, null, 2) : "";
|
||||
|
||||
const copy = () => {
|
||||
void navigator.clipboard?.writeText(raw).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
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 font-mono text-xs">{node.id}</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.type === "goal" || node.type === "intent" ? (
|
||||
<StatusBadge domain={node.type === "goal" ? "goal" : "intent"} value={node.state} dot className="px-1.5 py-0 text-[10px]" />
|
||||
) : (
|
||||
<span className="font-mono text-xs">{node.state}</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="优先级">
|
||||
<span className="tabular-nums">{node.priority}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="来源">
|
||||
<span className="font-mono text-xs">{node.origin}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
|
||||
</section>
|
||||
|
||||
<section className="border-t pt-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<h4 className="text-muted-foreground text-xs font-medium">原始数据</h4>
|
||||
<button type="button" onClick={copy} className="text-muted-foreground hover:text-foreground text-xs transition-colors">
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ExplorationGraphInner renders the given nodes/edges as a React Flow canvas.
|
||||
// Positions are auto-laid-out and preserved across prop updates (so a polling
|
||||
// caller's graph doesn't jump); click a node to open its detail drawer.
|
||||
function ExplorationGraphInner({
|
||||
nodes,
|
||||
edges,
|
||||
emptyHint = "暂无探索数据",
|
||||
}: {
|
||||
nodes: TaskNode[];
|
||||
edges: Edge[];
|
||||
emptyHint?: string;
|
||||
}) {
|
||||
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<ExploreRFNode>([]);
|
||||
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
|
||||
const [selected, setSelected] = React.useState<TaskNode | null>(null);
|
||||
const isEmpty = nodes.length === 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
|
||||
const liveTargets = new Set(
|
||||
nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
|
||||
);
|
||||
const layout = computeLayout(nodes, edges);
|
||||
setRfNodes((prev) => {
|
||||
const prevPos = new Map(prev.map((p) => [p.id, p.position]));
|
||||
return nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "explore" as const,
|
||||
position: prevPos.get(n.id) ?? layout.get(n.id) ?? { x: 0, y: 0 },
|
||||
data: { node: n },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}));
|
||||
});
|
||||
setRfEdges(
|
||||
edges.map((e, i) => {
|
||||
const color = relColor[e.rel] ?? "#94a3b8";
|
||||
const animated = liveTargets.has(e.dst);
|
||||
return {
|
||||
id: `e${i}-${e.src}-${e.dst}`,
|
||||
source: e.src,
|
||||
target: e.dst,
|
||||
label: relLabel[e.rel] ?? e.rel,
|
||||
type: "default",
|
||||
animated,
|
||||
labelShowBg: false,
|
||||
labelStyle: { fontSize: 10, fontWeight: 600, fill: color },
|
||||
style: { stroke: color, strokeWidth: 2, strokeOpacity: animated ? 0.9 : 0.55 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 },
|
||||
} satisfies RFEdge;
|
||||
}),
|
||||
);
|
||||
}, [nodes, edges, setRfNodes, setRfEdges]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, node) => setSelected(node.data.node)}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
minZoom={0.1}
|
||||
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 data = node.data as ExploreNodeData | undefined;
|
||||
const k = data?.node ? viewKind(data.node) : "intent";
|
||||
return typeMeta[k]?.hex ?? "#94a3b8";
|
||||
}}
|
||||
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">
|
||||
{isEmpty && <span className="text-muted-foreground">{emptyHint}</span>}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
{(["begin", "goal", "intent", "fact", "finding", "hint"] as ExploreKind[]).map((k) => {
|
||||
const m = typeMeta[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">
|
||||
{Object.entries(relLabel).map(([k, v]) => (
|
||||
<span key={k} className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: relColor[k] }} />
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
<NodeDetailSheet node={selected} onOpenChange={(o) => !o && setSelected(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ExplorationGraph is the shared探索图/攻击链路图 canvas — used by the task graph
|
||||
// tab (full graph, polled) and the finding lineage view (origin→finding subgraph).
|
||||
export function ExplorationGraph({
|
||||
nodes,
|
||||
edges,
|
||||
className,
|
||||
emptyHint,
|
||||
}: {
|
||||
nodes: TaskNode[];
|
||||
edges: Edge[];
|
||||
className?: string;
|
||||
emptyHint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("w-full overflow-hidden rounded-xl", className)}>
|
||||
<ReactFlowProvider>
|
||||
<ExplorationGraphInner nodes={nodes} edges={edges} emptyHint={emptyHint} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user