From 03ef9eb39a83a9b41c7174411ad04501275437cf Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 5 Aug 2026 21:34:20 +0800 Subject: [PATCH] Project discover neighbors from scanned seeds only. Accept optional seed ids on project-neighbors; canvas discover passes scanned fabric nodes and places new peers near them. Co-authored-by: Cursor --- netx_api/topology_router.py | 11 +++- netx_api/topology_schemas.py | 12 ++++ netx_api/topology_views_graph.py | 57 ++++++++++++++++--- .../src/netx_topology_mcp/http_tools.py | 27 ++++++++- tests/test_topology.py | 26 +++++++++ web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- web/src/pages/TopologyPage.tsx | 8 ++- web/src/services/api.ts | 10 +++- 9 files changed, 137 insertions(+), 18 deletions(-) diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 2cec3c6..8b70610 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -43,6 +43,7 @@ from .topology_schemas import ( ViewNodesRemove, ViewPopulateRequest, ViewPositionsPatch, + ViewProjectNeighborsRequest, ) from .topology_discover import get_discover_job, start_discover_job from .topology_fabric import ( @@ -319,8 +320,14 @@ def api_create_topology_placeholder( @router.post("/views/{view_id}/project-neighbors") -def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: - return project_fabric_neighbors_to_view(db, view_id).model_dump() +def api_project_neighbors( + view_id: str, + body: ViewProjectNeighborsRequest | None = None, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return project_fabric_neighbors_to_view( + db, view_id, body or ViewProjectNeighborsRequest() + ).model_dump() @router.post("/views/{view_id}/populate") diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 7ec40d9..c5c16de 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -238,6 +238,18 @@ class ViewPopulateRequest(BaseModel): freeze_after: bool = True +class ViewProjectNeighborsRequest(BaseModel): + """Project fabric neighbors onto a view. + + 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). + """ + + seed_fabric_node_ids: list[str] = Field(default_factory=list) + managed_ne_ids: list[str] = Field(default_factory=list) + + class ViewNodeIn(BaseModel): fabric_node_id: str = Field(min_length=1, max_length=64) x: float = 0.0 diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index 69e4033..bc04bb9 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -70,6 +70,7 @@ from .topology_schemas import ( ViewPopulateOut, ViewPopulateRequest, ViewPositionsPatch, + ViewProjectNeighborsRequest, ) # --------------------------------------------------------------------------- @@ -288,18 +289,31 @@ def _place_fabric_ids_on_view( fabric_ids: list[str], *, existing: set[str], + near_fabric_ids: set[str] | None = None, ) -> int: now = _utcnow() added = 0 vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() - max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0) - base_x = max_x + 200.0 + anchor = None + near = {str(x).strip() for x in (near_fabric_ids or set()) if str(x).strip()} + if near: + for vn in vnodes: + if vn.fabric_node_id in near: + anchor = vn + break + if anchor is not None: + base_x = float(anchor.x or 0.0) + 200.0 + base_y = float(anchor.y or 0.0) + else: + max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0) + base_x = max_x + 200.0 + base_y = 40.0 cols = max(1, int(len(fabric_ids) ** 0.5) or 1) for i, fid in enumerate(fabric_ids): if fid in existing or db.get(TopoFabricNode, fid) is None: continue x = base_x + (i % cols) * 180.0 - y = 40.0 + (i // cols) * 120.0 + y = base_y + (i // cols) * 120.0 db.add( TopoViewNode( id=uuid4().hex, @@ -897,8 +911,16 @@ def _neighbor_ids( return found -def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewGraphOut: - """Add in-scope fabric neighbors onto the leaf view (bounded by membership).""" +def project_fabric_neighbors_to_view( + db: Session, + view_id: str, + body: ViewProjectNeighborsRequest | None = None, +) -> TopologyViewGraphOut: + """Add in-scope fabric neighbors onto the leaf view (bounded by membership). + + Optional seeds limit expansion to neighbors of those fabric nodes (must + already be on the view). Empty seeds → expand from every canvas node. + """ merge_duplicate_fabric_nodes(db) view = _get_view_or_404(db, view_id) mem = _membership_for_view(view) @@ -909,6 +931,7 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG 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() # Drop placements pointing at missing fabric rows only (keep LLDP placeholders). @@ -929,7 +952,25 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG g.truncate_reason = g.truncate_reason or "membership_cap" return g - peer_ids = _neighbor_ids(db, seed_ids=existing, layer=layer, hops=hops) + seed_ids: set[str] = { + str(x).strip() for x in (req.seed_fabric_node_ids or []) if str(x).strip() + } + for mid in req.managed_ne_ids or []: + mid_s = str(mid or "").strip() + if not mid_s: + continue + for fid in existing: + fn = db.get(TopoFabricNode, fid) + if fn is not None and str(fn.managed_ne_id or "").strip() == mid_s: + seed_ids.add(fid) + if seed_ids: + seed_ids &= existing + if not seed_ids: + return get_view_graph(db, view.id) + else: + seed_ids = set(existing) + + peer_ids = _neighbor_ids(db, seed_ids=seed_ids, layer=layer, hops=hops) to_add: list[str] = [] for peer in sorted(peer_ids): if peer in existing: @@ -947,7 +988,9 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG truncated = len(peer_ids) > len(to_add) if to_add: - _place_fabric_ids_on_view(db, view, to_add, existing=existing) + _place_fabric_ids_on_view( + db, view, to_add, existing=existing, near_fabric_ids=seed_ids + ) db.commit() g = get_view_graph(db, view.id) if truncated: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py index fac88cc..db8d0b8 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py @@ -256,7 +256,14 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]: view_id = str(args.get("view_id") or "").strip() if not view_id: return {"ok": False, "error": "view_id_required"} - return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body={})) + body: dict[str, Any] = {} + seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or [] + if isinstance(seeds, list) and seeds: + body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()] + mids = args.get("managed_ne_ids") or [] + if isinstance(mids, list) and mids: + body["managed_ne_ids"] = [str(x).strip() for x in mids if str(x).strip()] + return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body=body)) def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]: @@ -515,11 +522,25 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "name": "projectTopologyNeighbors", "description": ( "Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. " - "Only places nodes that already exist in fabric." + "Only places nodes that already exist in fabric. " + "Optional seed_fabric_node_ids / managed_ne_ids limit expansion to those seeds; " + "omit to expand from every node on the view." ), "inputSchema": { "type": "object", - "properties": {"view_id": {"type": "string"}}, + "properties": { + "view_id": {"type": "string"}, + "seed_fabric_node_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional fabric node ids already on the view to expand from", + }, + "managed_ne_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional managed NE ids to resolve to on-view fabric seeds", + }, + }, "required": ["view_id"], "additionalProperties": False, }, diff --git a/tests/test_topology.py b/tests/test_topology.py index d77fd14..242370b 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -33,6 +33,7 @@ from netx_api.topology_schemas import ( ViewNodesAdd, ViewPopulateRequest, ViewPositionsPatch, + ViewProjectNeighborsRequest, ViewNodeIn, ) @@ -679,6 +680,31 @@ class FabricTopologyTests(unittest.TestCase): self.assertLessEqual(len(g.nodes), 2) self.assertTrue(g.truncated or len(g.nodes) == 2) + # Seed-scoped project: expand only from node 0 → only node 1 among line peers. + view2 = svc.create_view( + self.db, + TopologyViewCreate( + name=f"CapSeed-{suffix}", + folder_id=self._region(f"CapSeedR-{suffix}"), + role="core", + filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}}, + ), + ) + # Place endpoints 0 and 2 (not adjacent); seed from 0 should add 1, not 3. + svc.add_nodes_to_view( + self.db, view2.id, ViewNodesAdd(managed_ne_ids=[nes[0].id, nes[2].id]) + ) + g2 = svc.project_fabric_neighbors_to_view( + self.db, + view2.id, + ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]), + ) + ids2 = {n.fabric_node_id for n in g2.nodes} + self.assertIn(nodes[0].id, ids2) + self.assertIn(nodes[1].id, ids2) + self.assertIn(nodes[2].id, ids2) + self.assertNotIn(nodes[3].id, ids2) + pop = svc.populate_view( self.db, view.id, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a315e33..12049b1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1486,7 +1486,7 @@ const en = { snapGrid: "Snap to grid", autoLayoutDiscover: "Auto-layout after discover", discoverAutoAddUnmatched: "Auto-add unmatched NEs as placeholders on discover", - discoverProjectNeighbors: "Project direct neighbors onto canvas after discover", + discoverProjectNeighbors: "After discover, project neighbors of scanned NEs onto the canvas", undo: "Undo", redo: "Redo", discoverSelected: "Discover selected ({{count}})", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index ac4e5f5..cf8327d 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1477,7 +1477,7 @@ const zh = { snapGrid: "网格吸附", autoLayoutDiscover: "发现后自动布局", discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)", - discoverProjectNeighbors: "发现后将直连邻居加入画布", + discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布", undo: "撤销", redo: "重做", discoverSelected: "发现选中 ({{count}})", diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 9220b41..e48eacc 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1620,13 +1620,16 @@ export function TopologyPage() { throw new Error(job.error || "discover_failed"); } const projected = discoverProjectNeighbors - ? await projectTopologyNeighbors(mapId) + ? await projectTopologyNeighbors(mapId, { + seed_fabric_node_ids: scoped.map((n) => n.id), + }) : await fetchTopologyGraph(mapId); queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected); appliedMapIdRef.current = mapId; let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults); // 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)); rfNodes = rfNodes.map((n) => { const p = localPos.get(n.id); return p ? { ...n, position: { ...p } } : n; @@ -1649,7 +1652,8 @@ export function TopologyPage() { setNodes(rfNodes); setEdges(rfEdges); historyLockRef.current = false; - if (didAutoLayout) { + const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length; + if (didAutoLayout || addedNodes > 0) { needsInitialFitRef.current = true; scheduleFitView(FIT_VIEW_OPTS); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index b07dac0..6570a4a 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1084,8 +1084,14 @@ export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) fabric_node_ids: fabricNodeIds, }); -export const projectTopologyNeighbors = (viewId: string) => - apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, {}); +export const projectTopologyNeighbors = ( + viewId: string, + body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[] }, +) => + apiPost( + `/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, + body || {}, + ); export const patchTopologyEdgeStyle = ( viewId: string,