From 27e18f1c96824850eac814105b9108771b5b2557 Mon Sep 17 00:00:00 2001 From: Nicola Bottini Date: Thu, 9 Jul 2026 16:30:26 -0400 Subject: [PATCH] feat: auto-position nodes and auto-assign edge handles on create Co-authored-by: Cursor --- backend/app/api/routes/edges.py | 59 ++++++++++++++++++++++++++++++++- backend/app/api/routes/nodes.py | 56 +++++++++++++++++++++++++++++++ backend/app/schemas/nodes.py | 4 +++ mcp/app/tools.py | 4 +++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/edges.py b/backend/app/api/routes/edges.py index 4d63530..1255d9c 100644 --- a/backend/app/api/routes/edges.py +++ b/backend/app/api/routes/edges.py @@ -4,11 +4,56 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.db.database import get_db -from app.db.models import Design, Edge +from app.db.models import Design, Edge, Node from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate router = APIRouter() +# --------------------------------------------------------------------------- +# Auto-handle helpers +# --------------------------------------------------------------------------- + + +async def _abs_y(db: AsyncSession, node_id: str) -> float | None: + """Resolve the approximate absolute canvas Y of a node. + + Walks up the parent chain (up to 8 levels) and accumulates pos_y offsets so + that children inside containers are compared correctly against top-level nodes. + Returns None when the node is not found. + """ + node = await db.get(Node, node_id) + if node is None: + return None + y = node.pos_y + current = node + for _ in range(8): + if current.parent_id is None: + break + parent = await db.get(Node, current.parent_id) + if parent is None: + break + y += parent.pos_y + current = parent + return y + + +async def _auto_handles( + db: AsyncSession, source_id: str, target_id: str +) -> tuple[str, str]: + """Return (source_handle, target_handle) that reflect the upstream/downstream + relationship between two nodes. + + - Source above target (lower Y value) → downstream flow: exit bottom, enter top + - Source below target → upstream flow: exit top, enter bottom + - Equal or unknown → default to bottom/top-t (most common topology direction) + """ + src_y = await _abs_y(db, source_id) + tgt_y = await _abs_y(db, target_id) + + if src_y is None or tgt_y is None or src_y <= tgt_y: + return "bottom", "top-t" + return "top", "bottom-t" + @router.get("", response_model=list[EdgeResponse]) async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]: @@ -25,6 +70,18 @@ async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: s if data.get("design_id") is None: first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() data["design_id"] = first_design.id if first_design else None + + # Auto-assign source/target handles when the caller omits them. + # Compares the canvas Y positions of both nodes so that the edge always exits + # the upstream node's bottom and enters the downstream node's top (or vice versa + # for reverse flows), matching the UI convention for top-to-bottom topologies. + if data.get("source_handle") is None or data.get("target_handle") is None: + auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"]) + if data.get("source_handle") is None: + data["source_handle"] = auto_src + if data.get("target_handle") is None: + data["target_handle"] = auto_tgt + edge = Edge(**data) db.add(edge) await db.commit() diff --git a/backend/app/api/routes/nodes.py b/backend/app/api/routes/nodes.py index be04106..28ea124 100644 --- a/backend/app/api/routes/nodes.py +++ b/backend/app/api/routes/nodes.py @@ -10,6 +10,46 @@ from app.services.node_dedupe import find_duplicate_node router = APIRouter() +# --------------------------------------------------------------------------- +# Auto-positioning helpers +# --------------------------------------------------------------------------- + +# Canvas grid used when placing nodes without explicit coordinates. +# Each slot is wide/tall enough that a normal node card fits without overlap. +_SLOT_W = 200.0 +_SLOT_H = 100.0 +_MAX_COLS = 7 + + +async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[float, float]: + """Return (x, y) for a new root-level node that doesn't collide with existing ones. + + Snaps existing root nodes to a virtual grid and returns the first unoccupied + cell, scanning left-to-right then top-to-bottom. + """ + result = await db.execute( + select(Node.pos_x, Node.pos_y).where( + Node.design_id == design_id, + Node.parent_id.is_(None), + ) + ) + positions = list(result.all()) + if not positions: + return 0.0, 0.0 + + occupied: set[tuple[int, int]] = set() + for (px, py) in positions: + col = max(0, round(px / _SLOT_W)) + row = max(0, round(py / _SLOT_H)) + occupied.add((col, row)) + + for row in range(10_000): + for col in range(_MAX_COLS): + if (col, row) not in occupied: + return col * _SLOT_W, row * _SLOT_H + + return 0.0, 0.0 # unreachable in practice + @router.get("", response_model=list[NodeResponse]) async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]: @@ -38,6 +78,22 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s if dup is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=dup) + # Auto-position: when pos_x / pos_y are omitted (None), find a free canvas + # slot so the new node doesn't land on top of an existing one. + # Child nodes (parent_id set) use (0, 0) relative to their parent instead. + if data["pos_x"] is None or data["pos_y"] is None: + if data.get("parent_id") is None: + auto_x, auto_y = await _find_free_position(db, data["design_id"]) + if data["pos_x"] is None: + data["pos_x"] = auto_x + if data["pos_y"] is None: + data["pos_y"] = auto_y + else: + if data["pos_x"] is None: + data["pos_x"] = 0.0 + if data["pos_y"] is None: + data["pos_y"] = 0.0 + node = Node(**data) db.add(node) await db.commit() diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index e01fcc3..193e495 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -38,6 +38,10 @@ class NodeBase(BaseModel): class NodeCreate(NodeBase): + # Override pos_x/pos_y so callers can omit them; None signals "auto-place". + # The create_node route resolves None to a free grid slot before persisting. + pos_x: float | None = None # type: ignore[assignment] + pos_y: float | None = None # type: ignore[assignment] design_id: str | None = None # When a node with the same ip/mac already exists on the target design, the # create/approve endpoints reject with 409 so the UI can ask the user. Set diff --git a/mcp/app/tools.py b/mcp/app/tools.py index 399dae2..85ef890 100644 --- a/mcp/app/tools.py +++ b/mcp/app/tools.py @@ -21,6 +21,10 @@ _NODE_FIELDS = { "check_target": {"type": "string", "description": "Target host/URL used by the status check."}, "services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."}, "notes": {"type": "string", "description": "Free-text notes / documentation for the node."}, + "pos_x": {"type": "number", "description": "X position on the canvas. Omit on create to auto-place (root nodes only)."}, + "pos_y": {"type": "number", "description": "Y position on the canvas. Omit on create to auto-place (root nodes only). For child nodes this is relative to the parent container."}, + "width": {"type": "number", "description": "Width of the node card in pixels. Mainly useful for container nodes."}, + "height": {"type": "number", "description": "Height of the node card in pixels. Mainly useful for container nodes."}, "parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."}, "container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."}, "custom_icon": {"type": "string", "description": "Override icon name for the node."},