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