Make neighbor projection save-deferred and Visio-style connect.

Project-neighbors supports dry_run for local dirty canvas; Save flushes adds. Connect mode uses drag-from side anchors with smoothstep elbows instead of click-click.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 23:11:13 +08:00
parent a746a84d01
commit b4f0e468cb
11 changed files with 229 additions and 76 deletions

View file

@ -131,7 +131,8 @@ src/
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node`
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网。画布「投影邻居 / 发现后投影」默认 `dry_run`:只进本地脏图,**保存**时 `addTopologyViewNodes` 才落库。
- 连线模式:Visio 式四边锚点拖拽连线(非点选两点);默认折线 `smoothstep`。
- API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`)
- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台)
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web

View file

@ -1493,7 +1493,8 @@ const en = {
snapGrid: "Snap to grid",
autoLayoutDiscover: "Auto-layout after discover",
discoverAutoAddUnmatched: "Auto-add unmatched NEs as placeholders on discover",
discoverProjectNeighbors: "After discover, project neighbors of scanned NEs onto the canvas",
discoverProjectNeighbors:
"After discover, project neighbors of scanned NEs onto the canvas (Save to keep permanently)",
undo: "Undo",
redo: "Redo",
discoverSelected: "Discover selected ({{count}})",
@ -1621,7 +1622,7 @@ const en = {
removeStaleHint:
"Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses",
staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply",
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
connectHint: "Connect mode: drag from a node's side anchors to the target NE (Esc or Select to exit)",
},
topoClassify: {
title: "Classify & slices",

View file

@ -1484,7 +1484,7 @@ const zh = {
snapGrid: "网格吸附",
autoLayoutDiscover: "发现后自动布局",
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布",
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)",
undo: "撤销",
redo: "重做",
discoverSelected: "发现选中 ({{count}})",
@ -1608,7 +1608,7 @@ const zh = {
removeStale: "清除未发现 ({{count}})",
removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理",
staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)",
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
connectHint: "连线模式:从节点四边锚点拖到目标网元(Esc 或切回选择退出)",
},
topoClassify: {
title: "分类与切片",

View file

@ -7042,16 +7042,76 @@ pre {
.topo-node.is-connect-mode .topo-node__handle {
pointer-events: all;
opacity: 1 !important;
width: 12px !important;
height: 12px !important;
min-width: 12px !important;
min-height: 12px !important;
background: #f8fafc !important;
border: 2px solid #38bdf8 !important;
border-radius: 2px !important;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.35);
z-index: 5;
}
.topo-node__handle--center {
.topo-node.is-connect-mode .topo-node__handle:hover {
background: #38bdf8 !important;
border-color: #e0f2fe !important;
transform: scale(1.15);
}
.topo-node__handle--n {
left: 50% !important;
top: 0 !important;
right: auto !important;
bottom: auto !important;
transform: translate(-50%, -50%) !important;
}
.topo-node__handle--e {
left: auto !important;
top: 50% !important;
right: 0 !important;
bottom: auto !important;
transform: translate(50%, -50%) !important;
}
.topo-node__handle--s {
left: 50% !important;
top: auto !important;
right: auto !important;
bottom: 0 !important;
transform: translate(-50%, 50%) !important;
}
.topo-node__handle--w {
left: 0 !important;
top: 50% !important;
right: auto !important;
bottom: auto !important;
transform: translate(-50%, -50%) !important;
}
.topo-node.is-connect-mode .topo-node__handle--n:hover,
.topo-node.is-connect-mode .topo-node__handle--e:hover,
.topo-node.is-connect-mode .topo-node__handle--s:hover,
.topo-node.is-connect-mode .topo-node__handle--w:hover {
/* keep side translate while scaling */
}
.topo-node.is-connect-mode .topo-node__handle--n:hover {
transform: translate(-50%, -50%) scale(1.15) !important;
}
.topo-node.is-connect-mode .topo-node__handle--e:hover {
transform: translate(50%, -50%) scale(1.15) !important;
}
.topo-node.is-connect-mode .topo-node__handle--s:hover {
transform: translate(-50%, 50%) scale(1.15) !important;
}
.topo-node.is-connect-mode .topo-node__handle--w:hover {
transform: translate(-50%, -50%) scale(1.15) !important;
}
.topo-node.is-selected .topo-node__glyph {
outline: 2px solid #60a5fa;
outline-offset: 3px;

View file

@ -23,6 +23,7 @@ import {
useNodesState,
Handle,
Position,
ConnectionLineType,
ConnectionMode,
SelectionMode,
type Connection,
@ -430,6 +431,12 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
const handlePositions = [
{ id: "n", position: Position.Top },
{ id: "e", position: Position.Right },
{ id: "s", position: Position.Bottom },
{ id: "w", position: Position.Left },
] as const;
return (
<div
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
@ -438,18 +445,16 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
title={showBadge ? data.managed_source || "placeholder" : undefined}
>
<div className="topo-node__glyph">
<Handle
type="target"
position={Position.Left}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode}
/>
<Handle
type="source"
position={Position.Right}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode}
/>
{handlePositions.map(({ id, position }) => (
<Handle
key={id}
id={id}
type="source"
position={position}
className={`topo-node__handle topo-node__handle--${id}`}
isConnectable={connectMode}
/>
))}
<RouterIcon />
{showBadge ? (
<span className="topo-node__badge" aria-hidden>
@ -738,11 +743,13 @@ function graphToFlow(
position: { x: n.x || 0, y: n.y || 0 },
width: TOPO_NODE_W,
height: TOPO_NODE_H,
// Predetermined handles must match DOM anchors (icon center), not the 160px box edges —
// otherwise edges float in the gap and never touch the router glyph.
// Predetermined handles must match DOM anchors on the router glyph (56×56, top-centered).
// ConnectionMode.Loose treats these source handles as connectable targets too.
handles: [
{ type: "target", position: Position.Left, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y },
{ type: "source", position: Position.Right, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y },
{ type: "source", position: Position.Top, id: "n", x: TOPO_HANDLE_X, y: 0 },
{ type: "source", position: Position.Right, id: "e", x: TOPO_HANDLE_X + TOPO_ICON / 2, y: TOPO_HANDLE_Y },
{ type: "source", position: Position.Bottom, id: "s", x: TOPO_HANDLE_X, y: TOPO_ICON },
{ type: "source", position: Position.Left, id: "w", x: TOPO_HANDLE_X - TOPO_ICON / 2, y: TOPO_HANDLE_Y },
],
data: {
label: n.label || n.name || n.ip || n.fabric_node_id,
@ -772,7 +779,7 @@ function graphToFlow(
id: e.id,
source: e.a_node_id,
target: e.b_node_id,
type: "straight",
type: "smoothstep",
label: label || undefined,
animated: false,
data,
@ -1536,12 +1543,18 @@ export function TopologyPage() {
await deleteFabricEdges(pendingEdges);
pendingEdgeDeletesRef.current.clear();
}
// Positions patch only updates listed nodes; sync canvas removals first.
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = new Set(nodes.map((n) => n.id));
const toRemove = serverIds.filter((id) => !localIds.has(id));
const localIds = nodes.map((n) => n.id);
const localSet = new Set(localIds);
const serverSet = new Set(serverIds);
const toAdd = localIds.filter((id) => !serverSet.has(id));
const toRemove = serverIds.filter((id) => !localSet.has(id));
// Add first so a save that both adds and removes never flashes an empty view.
if (toAdd.length) {
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
}
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
@ -1596,8 +1609,14 @@ export function TopologyPage() {
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = new Set(nodes.map((n) => n.id));
const toRemove = serverIds.filter((id) => !localIds.has(id));
const localIds = nodes.map((n) => n.id);
const localSet = new Set(localIds);
const serverSet = new Set(serverIds);
const toAdd = localIds.filter((id) => !serverSet.has(id));
const toRemove = serverIds.filter((id) => !localSet.has(id));
if (toAdd.length) {
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
}
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
@ -1673,12 +1692,16 @@ export function TopologyPage() {
} catch {
/* keep last polled job */
}
// Refresh base graph from server (LLDP may have new edges), then dry-run project.
const baseGraph = await fetchTopologyGraph(mapId);
const projected = discoverProjectNeighbors
? await projectTopologyNeighbors(mapId, {
seed_fabric_node_ids: scoped.map((n) => n.id),
dry_run: true,
})
: await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
: baseGraph;
// Keep server query as committed graph (dry_run must not poison cache as persisted).
queryClient.setQueryData(queryKeys.topologyGraph(mapId), baseGraph);
if (discoverProjectNeighbors && projected.truncate_reason === "membership_frozen") {
showError(t("topology.truncatedFrozen"));
}
@ -1687,6 +1710,7 @@ export function TopologyPage() {
// Keep existing node positions when we did not auto-layout.
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const beforeIds = new Set(nodes.map((n) => n.id));
const serverNodeIds = new Set((baseGraph.nodes || []).map((n) => n.fabric_node_id));
rfNodes = rfNodes.map((n) => {
const p = localPos.get(n.id);
return p ? { ...n, position: { ...p } } : n;
@ -1695,21 +1719,18 @@ export function TopologyPage() {
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
didAutoLayout = true;
try {
const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes));
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
clearDirty();
} catch {
markDirty();
}
} else {
clearDirty();
}
historyLockRef.current = true;
setNodes(rfNodes);
setEdges(rfEdges);
historyLockRef.current = false;
const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length;
const localOnly = rfNodes.some((n) => !serverNodeIds.has(n.id));
if (localOnly || didAutoLayout) {
markDirty();
} else {
clearDirty();
}
if (didAutoLayout || addedNodes > 0) {
needsInitialFitRef.current = true;
scheduleFitView(FIT_VIEW_OPTS);
@ -2195,35 +2216,32 @@ export function TopologyPage() {
if (!mapId) return;
try {
const before = nodes.length;
const projected = await projectTopologyNeighbors(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
appliedMapIdRef.current = mapId;
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
historyLockRef.current = true;
applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
const projected = await projectTopologyNeighbors(mapId, { dry_run: true });
if (projected.truncate_reason === "membership_frozen") {
showError(t("topology.truncatedFrozen"));
return;
}
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
historyLockRef.current = true;
applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
const added = Math.max(0, projected.nodes.length - before);
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added)));
if (added > 0) {
markDirty();
needsInitialFitRef.current = true;
scheduleFitView(FIT_VIEW_OPTS);
}
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added)));
} catch (err) {
showError(String(err));
}
}, [
mapId,
nodes,
queryClient,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
markDirty,
showOk,
showError,
t,
@ -2769,22 +2787,11 @@ export function TopologyPage() {
const onNodeClick = useCallback(
(e: React.MouseEvent, node: Node<NeNodeData>) => {
setCtxMenu(null);
if (toolMode === "connect") {
const prev = connectClickRef.current;
if (!prev) {
connectClickRef.current = node.id;
focusNode(node.id, false);
return;
}
if (prev !== node.id) {
onConnect({ source: prev, target: node.id, sourceHandle: null, targetHandle: null });
}
connectClickRef.current = null;
return;
}
// Connect mode uses Visio-style drag from handles (onConnect), not click-click.
if (toolMode === "connect") return;
focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey);
},
[toolMode, focusNode, onConnect],
[toolMode, focusNode],
);
const outsidePeers = graphQuery.data?.outside_peers || [];
@ -3478,7 +3485,6 @@ export function TopologyPage() {
}}
/>
{t("topology.discoverProjectNeighbors")}
<span className="muted"> ({t("topology.localBrowserOnly")})</span>
</label>
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
@ -4131,7 +4137,9 @@ export function TopologyPage() {
edgeTypes={edgeTypes}
onlyRenderVisibleElements
connectionMode={ConnectionMode.Loose}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
defaultEdgeOptions={{ type: "smoothstep", labelShowBg: false }}
proOptions={{ hideAttribution: true }}
minZoom={0.05}
maxZoom={4}

View file

@ -1,12 +1,14 @@
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getStraightPath } from "@xyflow/react";
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
/** Offset parallel physical links so they do not stack on one line. */
/** Offset parallel physical links so they do not stack on one line (Visio-like elbows). */
export function ParallelEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
label,
@ -16,17 +18,20 @@ export function ParallelEdge({
}: EdgeProps) {
const index = Number((data as { parallelIndex?: number } | undefined)?.parallelIndex || 0);
const count = Math.max(1, Number((data as { parallelCount?: number } | undefined)?.parallelCount || 1));
const offset = (index - (count - 1) / 2) * 10;
const offset = (index - (count - 1) / 2) * 12;
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const len = Math.hypot(dx, dy) || 1;
const ox = (-dy / len) * offset;
const oy = (dx / len) * offset;
const [path, labelX, labelY] = getStraightPath({
const [path, labelX, labelY] = getSmoothStepPath({
sourceX: sourceX + ox,
sourceY: sourceY + oy,
targetX: targetX + ox,
targetY: targetY + oy,
sourcePosition,
targetPosition,
borderRadius: 8,
});
return (
<>

View file

@ -116,7 +116,7 @@ export function buildLinkDisplayEdges(
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
out.push({
...e,
type: count > 1 ? "topoParallel" : e.type || "straight",
type: count > 1 ? "topoParallel" : e.type || "smoothstep",
// Never fall back to a stale e.label — it may still contain ports.
label: hidePorts ? undefined : portLabel || undefined,
data,
@ -137,7 +137,7 @@ export function buildLinkDisplayEdges(
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
out.push({
...e,
type: "straight",
type: "smoothstep",
label: hidePorts ? undefined : portLabel || undefined,
data,
});
@ -162,7 +162,7 @@ export function buildLinkDisplayEdges(
out.push({
...primary,
id: aggregateIdForPair(primary.source, primary.target),
type: "straight",
type: "smoothstep",
label: formatBundleLabel(
list.length,
{ a_port: data.source_port, b_port: data.target_port },

View file

@ -1086,7 +1086,7 @@ export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[])
export const projectTopologyNeighbors = (
viewId: string,
body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[] },
body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[]; dry_run?: boolean },
) =>
apiPost<TopologyViewGraph>(
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,