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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 21:34:20 +08:00
parent 7cd89aaae9
commit 03ef9eb39a
9 changed files with 137 additions and 18 deletions

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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,
},

View file

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

View file

@ -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}})",

View file

@ -1477,7 +1477,7 @@ const zh = {
snapGrid: "网格吸附",
autoLayoutDiscover: "发现后自动布局",
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
discoverProjectNeighbors: "发现后将直连邻居加入画布",
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布",
undo: "撤销",
redo: "重做",
discoverSelected: "发现选中 ({{count}})",

View file

@ -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);
}

View file

@ -1084,8 +1084,14 @@ export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[])
fabric_node_ids: fabricNodeIds,
});
export const projectTopologyNeighbors = (viewId: string) =>
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, {});
export const projectTopologyNeighbors = (
viewId: string,
body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[] },
) =>
apiPost<TopologyViewGraph>(
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
body || {},
);
export const patchTopologyEdgeStyle = (
viewId: string,