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: