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

@ -244,10 +244,14 @@ class ViewProjectNeighborsRequest(BaseModel):
When seed ids are omitted, expand from every node already on the canvas.
When provided, expand only from those seeds (resolved to fabric nodes that
are already placed on the view).
dry_run=True: return the would-be graph without writing placements (canvas
can apply locally and persist on Save).
"""
seed_fabric_node_ids: list[str] = Field(default_factory=list)
managed_ne_ids: list[str] = Field(default_factory=list)
dry_run: bool = False
class ViewNodeIn(BaseModel):

View file

@ -967,10 +967,14 @@ def project_fabric_neighbors_to_view(
Optional seeds limit expansion to neighbors of those fabric nodes (must
already be on the view). Empty seeds → expand from every canvas node.
dry_run=True returns the projected graph without persisting placements.
"""
merge_duplicate_fabric_nodes(db)
view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view)
req = body or ViewProjectNeighborsRequest()
dry_run = bool(req.dry_run)
if bool(mem.get("frozen")):
g = get_view_graph(db, view.id)
g.truncated = True
@ -981,7 +985,6 @@ def project_fabric_neighbors_to_view(
hops = int(mem.get("expand_hops") or 1)
filt = dict(view.filter or {})
layer = str(filt.get("layer") or "physical").strip() or "physical"
req = body or ViewProjectNeighborsRequest()
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
fids_on_view = [vn.fabric_node_id for vn in vnodes]
@ -995,7 +998,7 @@ def project_fabric_neighbors_to_view(
)
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
orphan_vns = [vn for vn in vnodes if vn.fabric_node_id not in fabric_on_view]
if orphan_vns:
if orphan_vns and not dry_run:
for vn in orphan_vns:
db.delete(vn)
view.updated_at = _utcnow()
@ -1064,6 +1067,25 @@ def project_fabric_neighbors_to_view(
room = max(0, max_nodes - len(existing))
to_add = eligible[:room]
truncated = len(eligible) > len(to_add)
if dry_run:
if to_add:
nested = db.begin_nested()
try:
_place_fabric_ids_on_view(
db, view, to_add, existing=set(existing), near_fabric_ids=seed_ids
)
db.flush()
g = get_view_graph(db, view.id)
finally:
nested.rollback()
else:
g = get_view_graph(db, view.id)
if truncated:
g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap"
return g
if to_add:
_place_fabric_ids_on_view(
db, view, to_add, existing=existing, near_fabric_ids=seed_ids

View file

@ -724,6 +724,58 @@ class FabricTopologyTests(unittest.TestCase):
self.assertLessEqual(pop.candidate_count, 4)
self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2)
def test_project_neighbors_dry_run_does_not_persist(self) -> None:
suffix = uuid4().hex[:8]
nes = []
for i in range(3):
ne = ManagedNE(
id=f"dry-{suffix}-{i}",
name=f"D{i}-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.19.{(int(suffix[:2], 16) % 200)}.{i + 1}",
)
self.db.add(ne)
nes.append(ne)
self.db.commit()
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
self.db.commit()
for a, b in ((0, 1), (1, 2)):
svc.upsert_fabric_edge(
self.db,
a_node_id=nodes[a].id,
b_node_id=nodes[b].id,
a_port=f"Gi0/{a}",
b_port=f"Gi0/{b}",
source="lldp",
)
self.db.commit()
view = svc.create_view(
self.db,
TopologyViewCreate(
name=f"Dry-{suffix}",
folder_id=self._region(f"DryR-{suffix}"),
role="core",
filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
),
)
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id]))
before = (
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count()
)
g = svc.project_fabric_neighbors_to_view(
self.db,
view.id,
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id], dry_run=True),
)
ids = {n.fabric_node_id for n in g.nodes}
self.assertIn(nodes[0].id, ids)
self.assertIn(nodes[1].id, ids)
after = (
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count()
)
self.assertEqual(after, before)
def test_lldp_discover_writes_fabric_edge(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(

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`,