diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 0e5acea..e820b79 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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): diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index c1a5f32..e67b2fc 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -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 diff --git a/tests/test_topology.py b/tests/test_topology.py index 3b1db7d..39f77cb 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -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( diff --git a/web/WEB.md b/web/WEB.md index 25dc17e..974a0c0 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -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 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 923a146..35d8802 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 81ee9c9..370976c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "分类与切片", diff --git a/web/src/index.css b/web/src/index.css index 5d2f9f7..936c405 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 8a3c91a..58b53d9 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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
- - + {handlePositions.map(({ id, position }) => ( + + ))} {showBadge ? ( @@ -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) => { 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")} - ({t("topology.localBrowserOnly")})
@@ -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} diff --git a/web/src/pages/topology/ParallelEdge.tsx b/web/src/pages/topology/ParallelEdge.tsx index 9b9fcf1..f02ca55 100644 --- a/web/src/pages/topology/ParallelEdge.tsx +++ b/web/src/pages/topology/ParallelEdge.tsx @@ -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 ( <> diff --git a/web/src/pages/topology/linkDisplay.ts b/web/src/pages/topology/linkDisplay.ts index f154b78..1dc4cb1 100644 --- a/web/src/pages/topology/linkDisplay.ts +++ b/web/src/pages/topology/linkDisplay.ts @@ -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 }, diff --git a/web/src/services/api.ts b/web/src/services/api.ts index f3da724..625931e 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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( `/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,