mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 17:20:46 +08:00
Optimize topology canvas per the 2026-10-12 plan.
Save path: one atomic POST /views/{id}/commit (edges, ports, styles, node
add/remove, positions) replaces the multi-request save. Edge styles and
manual-edge port edits now go through dirty/undo/save instead of writing
immediately; non-manual edge ports are read-only.
Data safety: UI saves no longer clear MCP node locks or freeze the device
name into the placement label (label_override + omit-means-keep semantics,
also applied to the MCP positions tool). Adding NEs/neighbors with unsaved
edits now saves first instead of silently dropping them. Positions no longer
bypass membership caps, except persisting synthesized child-region icons.
Fixes found in acceptance: nodes added in the same commit kept (40,40)
under autoflush=False; edits on edges deleted in the same save 404'd the
whole save; box-selection drags and arrow-key moves were not marked dirty;
large canvases far from the origin opened blank and locate did nothing
because fitView waits for measurement that onlyRenderVisibleElements never
does (now fitBounds from known geometry).
Also: batch N+1 lookups in graph/positions, deterministic truncation,
outside-peer scan excludes internal edges, split TopologyPage into hooks,
fix flaky UME/LLDP port test, add scripts/topology_clear_frozen_labels.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60a0ea6021
commit
f301544b99
40 changed files with 5587 additions and 1916 deletions
|
|
@ -121,6 +121,39 @@ def place_child_region_on_parent_canvas(
|
|||
return row
|
||||
|
||||
|
||||
def add_missing_region_placement(
|
||||
db: Session, view: TopoView, fabric_node_id: str, *, x: float, y: float
|
||||
) -> TopoViewNode | None:
|
||||
"""Persist a child-region icon that was only synthesized (no placement row yet).
|
||||
|
||||
Returns None unless ``fabric_node_id`` is a manual child region of the view's folder.
|
||||
"""
|
||||
if not is_region_canvas_node_id(fabric_node_id) or _is_world_flat(view):
|
||||
return None
|
||||
folder = db.get(TopoFolder, region_folder_id_from_node(fabric_node_id))
|
||||
if (
|
||||
folder is None
|
||||
or str(folder.kind or "") != "region"
|
||||
or str(folder.parent_id or "") != str(view.folder_id or "")
|
||||
or _is_ume_managed_folder(folder)
|
||||
):
|
||||
return None
|
||||
now = _utcnow()
|
||||
row = TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_node_id=fabric_node_id,
|
||||
x=float(x),
|
||||
y=float(y),
|
||||
label="",
|
||||
locked=False,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def remove_region_canvas_placements(db: Session, folder_id: str) -> int:
|
||||
"""Drop this folder's icon from any parent canvas."""
|
||||
nid = region_canvas_node_id(folder_id)
|
||||
|
|
@ -139,6 +172,7 @@ def region_node_out(
|
|||
y: float,
|
||||
locked: bool = False,
|
||||
label: str = "",
|
||||
label_override: str | None = None,
|
||||
) -> ViewNodeOut:
|
||||
child_view = _child_primary_view(db, folder.id)
|
||||
kids = (
|
||||
|
|
@ -154,12 +188,14 @@ def region_node_out(
|
|||
.filter(TopoViewNode.view_id == child_view.id)
|
||||
.count()
|
||||
)
|
||||
display = (label or folder.name or folder.id)[:256]
|
||||
raw = str(label_override if label_override is not None else label or "")
|
||||
display = (raw or folder.name or folder.id)[:256]
|
||||
return ViewNodeOut(
|
||||
fabric_node_id=region_canvas_node_id(folder.id),
|
||||
managed_ne_id="",
|
||||
ume_ne_id="",
|
||||
label=display,
|
||||
label_override=raw[:256],
|
||||
x=float(x),
|
||||
y=float(y),
|
||||
locked=bool(locked),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ from .topology_schemas import (
|
|||
TopologyPlaceholderCreate,
|
||||
TopologyViewCreate,
|
||||
TopologyViewUpdate,
|
||||
FabricEdgePortsPatch,
|
||||
ViewCommitIn,
|
||||
ViewEdgeStylePatch,
|
||||
ViewNodesAdd,
|
||||
ViewNodesRemove,
|
||||
|
|
@ -61,6 +63,7 @@ from .topology_fabric import (
|
|||
from .topology_views import (
|
||||
add_nodes_to_view,
|
||||
bootstrap_topology_tree,
|
||||
commit_view_changes,
|
||||
create_folder,
|
||||
create_topology_placeholder_on_view,
|
||||
create_view,
|
||||
|
|
@ -69,6 +72,7 @@ from .topology_views import (
|
|||
get_topology_tree,
|
||||
get_view_graph,
|
||||
list_views,
|
||||
patch_manual_edge_ports,
|
||||
patch_view_edge_style,
|
||||
patch_view_positions,
|
||||
populate_view,
|
||||
|
|
@ -481,6 +485,32 @@ def api_edge_style(
|
|||
return patch_view_edge_style(db, view_id, body).model_dump()
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/commit")
|
||||
def api_commit_view(
|
||||
view_id: str, body: ViewCommitIn, db: Session = Depends(get_db)
|
||||
) -> dict[str, Any]:
|
||||
return commit_view_changes(db, view_id, body).model_dump()
|
||||
|
||||
|
||||
@router.patch("/fabric/edges/{edge_id}")
|
||||
def api_patch_fabric_edge_ports(
|
||||
edge_id: str, body: FabricEdgePortsPatch, db: Session = Depends(get_db)
|
||||
) -> dict[str, Any]:
|
||||
edge = patch_manual_edge_ports(
|
||||
db, edge_id, a_port=body.a_port, b_port=body.b_port, commit=True
|
||||
)
|
||||
return {
|
||||
"id": edge.id,
|
||||
"a_node_id": edge.a_node_id,
|
||||
"b_node_id": edge.b_node_id,
|
||||
"a_port": edge.a_port or "",
|
||||
"b_port": edge.b_port or "",
|
||||
"source": edge.source or "manual",
|
||||
"status": edge.status or "active",
|
||||
"layer": edge.layer or "physical",
|
||||
}
|
||||
|
||||
|
||||
# --- Classify rules / slices / search ---------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -273,8 +273,10 @@ class ViewNodeIn(BaseModel):
|
|||
fabric_node_id: str = Field(min_length=1, max_length=64)
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
label: str = ""
|
||||
locked: bool = False
|
||||
# None = leave existing override unchanged; "" = clear override (follow device name).
|
||||
label: str | None = None
|
||||
# None = leave lock unchanged; False unlocks (and allows move); True locks after move.
|
||||
locked: bool | None = None
|
||||
|
||||
|
||||
class ViewNodeOut(BaseModel):
|
||||
|
|
@ -282,6 +284,8 @@ class ViewNodeOut(BaseModel):
|
|||
managed_ne_id: str = ""
|
||||
ume_ne_id: str = ""
|
||||
label: str = ""
|
||||
# Raw placement override (empty = follow fabric name/ip). ``label`` is the display value.
|
||||
label_override: str = ""
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
locked: bool = False
|
||||
|
|
@ -448,6 +452,16 @@ class ViewNodesRemove(BaseModel):
|
|||
|
||||
|
||||
class ViewEdgeStylePatch(BaseModel):
|
||||
"""Single-edge style patch (legacy). Prefer ``items`` for batch."""
|
||||
|
||||
fabric_edge_id: str = ""
|
||||
stroke_color: str = ""
|
||||
stroke_width: int = Field(default=0, ge=0, le=12)
|
||||
line_style: str = Field(default="", max_length=16)
|
||||
items: list["ViewEdgeStyleItem"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ViewEdgeStyleItem(BaseModel):
|
||||
fabric_edge_id: str
|
||||
stroke_color: str = ""
|
||||
stroke_width: int = Field(default=0, ge=0, le=12)
|
||||
|
|
@ -461,6 +475,11 @@ class FabricManualEdgeIn(BaseModel):
|
|||
b_port: str = ""
|
||||
|
||||
|
||||
class FabricEdgePortsPatch(BaseModel):
|
||||
a_port: str = ""
|
||||
b_port: str = ""
|
||||
|
||||
|
||||
class FabricEdgesDeleteRequest(BaseModel):
|
||||
edge_ids: list[str] = Field(default_factory=list, min_length=1)
|
||||
|
||||
|
|
@ -469,6 +488,40 @@ class FabricEdgesDeleteOut(BaseModel):
|
|||
deleted: int = 0
|
||||
|
||||
|
||||
class ViewCommitEdgeCreate(BaseModel):
|
||||
client_id: str = ""
|
||||
a_node_id: str = Field(min_length=1, max_length=64)
|
||||
b_node_id: str = Field(min_length=1, max_length=64)
|
||||
a_port: str = ""
|
||||
b_port: str = ""
|
||||
|
||||
|
||||
class ViewCommitEdgePortEdit(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
a_port: str = ""
|
||||
b_port: str = ""
|
||||
|
||||
|
||||
class ViewCommitIn(BaseModel):
|
||||
"""Atomic canvas save: all steps succeed or the whole request rolls back."""
|
||||
|
||||
edge_creates: list[ViewCommitEdgeCreate] = Field(default_factory=list)
|
||||
edge_deletes: list[str] = Field(default_factory=list)
|
||||
edge_port_edits: list[ViewCommitEdgePortEdit] = Field(default_factory=list)
|
||||
edge_styles: list[ViewEdgeStyleItem] = Field(default_factory=list)
|
||||
node_adds: list[str] = Field(default_factory=list)
|
||||
node_removes: list[str] = Field(default_factory=list)
|
||||
positions: list[ViewNodeIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ViewCommitOut(BaseModel):
|
||||
ok: bool = True
|
||||
view_id: str = ""
|
||||
edge_id_map: dict[str, str] = Field(default_factory=dict)
|
||||
skipped_locked: int = 0
|
||||
graph: TopologyViewGraphOut | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classify rules + slices + search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from .topology_views import (
|
|||
_place_fabric_ids_on_view,
|
||||
add_nodes_to_view,
|
||||
bootstrap_topology_tree,
|
||||
commit_view_changes,
|
||||
create_folder,
|
||||
create_topology_placeholder_on_view,
|
||||
create_view,
|
||||
|
|
@ -48,6 +49,7 @@ from .topology_views import (
|
|||
get_topology_tree,
|
||||
get_view_graph,
|
||||
list_views,
|
||||
patch_manual_edge_ports,
|
||||
patch_view_edge_style,
|
||||
patch_view_positions,
|
||||
populate_view,
|
||||
|
|
@ -68,6 +70,7 @@ __all__ = [
|
|||
"_place_fabric_ids_on_view",
|
||||
"add_nodes_to_view",
|
||||
"bootstrap_topology_tree",
|
||||
"commit_view_changes",
|
||||
"create_folder",
|
||||
"create_topology_placeholder_on_view",
|
||||
"create_view",
|
||||
|
|
@ -88,6 +91,7 @@ __all__ = [
|
|||
"list_fabric_nodes",
|
||||
"list_views",
|
||||
"merge_duplicate_fabric_nodes",
|
||||
"patch_manual_edge_ports",
|
||||
"patch_view_edge_style",
|
||||
"patch_view_positions",
|
||||
"pause_discover_job",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Topology folder tree and leaf view operations (facade)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .topology_views_commit import commit_view_changes, patch_manual_edge_ports
|
||||
from .topology_views_graph import (
|
||||
_place_fabric_ids_on_view,
|
||||
add_nodes_to_view,
|
||||
|
|
@ -29,6 +30,7 @@ __all__ = [
|
|||
"_place_fabric_ids_on_view",
|
||||
"add_nodes_to_view",
|
||||
"bootstrap_topology_tree",
|
||||
"commit_view_changes",
|
||||
"create_folder",
|
||||
"create_topology_placeholder_on_view",
|
||||
"create_view",
|
||||
|
|
@ -38,6 +40,7 @@ __all__ = [
|
|||
"get_topology_tree",
|
||||
"get_view_graph",
|
||||
"list_views",
|
||||
"patch_manual_edge_ports",
|
||||
"patch_view_edge_style",
|
||||
"patch_view_positions",
|
||||
"populate_view",
|
||||
|
|
|
|||
444
netx_api/topology_views_commit.py
Normal file
444
netx_api/topology_views_commit.py
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
"""Atomic view canvas commit (single transaction)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import (
|
||||
TopoFabricEdge,
|
||||
TopoFabricNode,
|
||||
TopoViewEdgeStyle,
|
||||
TopoViewNode,
|
||||
)
|
||||
from .topology_common import VIEW_GRAPH_NODE_HARD_CAP, _utcnow
|
||||
from .topology_fabric_links import upsert_fabric_edge
|
||||
from .topology_fabric_nodes import _normalize_endpoints, refresh_fabric_stats
|
||||
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
|
||||
from .topology_schemas import (
|
||||
TopologyViewGraphOut,
|
||||
ViewCommitIn,
|
||||
ViewCommitOut,
|
||||
ViewEdgeStyleItem,
|
||||
ViewNodeIn,
|
||||
)
|
||||
from .topology_views_graph import (
|
||||
_get_view_or_404,
|
||||
_membership_for_view,
|
||||
get_view_graph,
|
||||
)
|
||||
from .ume_topology_world import is_world_flat_view
|
||||
|
||||
_HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
_LINE_STYLES = {"", "solid", "dashed", "dotted"}
|
||||
|
||||
|
||||
def _fail(step: str, detail: str, *, status: int = 400) -> None:
|
||||
raise HTTPException(
|
||||
status_code=status,
|
||||
detail={"failed_step": step, "detail": detail},
|
||||
)
|
||||
|
||||
|
||||
def _apply_edge_creates(
|
||||
db: Session, creates: list[Any], edge_id_map: dict[str, str]
|
||||
) -> None:
|
||||
for item in creates:
|
||||
a = str(getattr(item, "a_node_id", "") or "").strip()
|
||||
b = str(getattr(item, "b_node_id", "") or "").strip()
|
||||
if not a or not b:
|
||||
_fail("edge_creates", "edge_endpoints_required")
|
||||
edge, action = upsert_fabric_edge(
|
||||
db,
|
||||
a_node_id=a,
|
||||
b_node_id=b,
|
||||
a_port=str(getattr(item, "a_port", "") or ""),
|
||||
b_port=str(getattr(item, "b_port", "") or ""),
|
||||
source="manual",
|
||||
)
|
||||
if edge is None or action == "skipped_self_loop":
|
||||
_fail("edge_creates", "edge_self_loop")
|
||||
client_id = str(getattr(item, "client_id", "") or "").strip()
|
||||
if client_id:
|
||||
edge_id_map[client_id] = str(edge.id)
|
||||
|
||||
|
||||
def _apply_edge_deletes(db: Session, edge_ids: list[str]) -> set[str]:
|
||||
"""Delete edges; return the ids that were requested (for skipping later edits)."""
|
||||
ids = [str(x).strip() for x in (edge_ids or []) if str(x).strip()]
|
||||
if not ids:
|
||||
return set()
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for i in ids:
|
||||
if i in seen:
|
||||
continue
|
||||
seen.add(i)
|
||||
ordered.append(i)
|
||||
rows = db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(ordered)).all()
|
||||
found_ids = [str(r.id) for r in rows]
|
||||
if not found_ids:
|
||||
return seen
|
||||
db.query(TopoViewEdgeStyle).filter(
|
||||
TopoViewEdgeStyle.fabric_edge_id.in_(found_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(found_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def patch_manual_edge_ports(
|
||||
db: Session,
|
||||
edge_id: str,
|
||||
*,
|
||||
a_port: str,
|
||||
b_port: str,
|
||||
commit: bool = True,
|
||||
) -> TopoFabricEdge:
|
||||
"""Update ports on a manual fabric edge. Raises 400/404/409."""
|
||||
eid = str(edge_id or "").strip()
|
||||
row = db.get(TopoFabricEdge, eid) if eid else None
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
|
||||
if str(row.source or "").strip().lower() != "manual":
|
||||
raise HTTPException(status_code=400, detail="edge_not_manual")
|
||||
_a, _b, ap, bp = _normalize_endpoints(
|
||||
row.a_node_id,
|
||||
row.b_node_id,
|
||||
str(a_port or ""),
|
||||
str(b_port or ""),
|
||||
)
|
||||
conflict = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
TopoFabricEdge.id != row.id,
|
||||
TopoFabricEdge.layer == row.layer,
|
||||
TopoFabricEdge.a_node_id == row.a_node_id,
|
||||
TopoFabricEdge.b_node_id == row.b_node_id,
|
||||
TopoFabricEdge.a_port == ap,
|
||||
TopoFabricEdge.b_port == bp,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if conflict is not None:
|
||||
raise HTTPException(status_code=409, detail="edge_port_conflict")
|
||||
row.a_port = ap
|
||||
row.b_port = bp
|
||||
row.updated_at = _utcnow()
|
||||
try:
|
||||
db.flush()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="edge_port_conflict") from exc
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _apply_edge_port_edits(db: Session, edits: list[Any], *, deleted: set[str]) -> None:
|
||||
for item in edits:
|
||||
eid = str(getattr(item, "id", "") or "").strip()
|
||||
if not eid:
|
||||
_fail("edge_port_edits", "edge_id_required")
|
||||
if eid in deleted:
|
||||
continue
|
||||
try:
|
||||
patch_manual_edge_ports(
|
||||
db,
|
||||
eid,
|
||||
a_port=str(getattr(item, "a_port", "") or ""),
|
||||
b_port=str(getattr(item, "b_port", "") or ""),
|
||||
commit=False,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
detail = exc.detail if isinstance(exc.detail, str) else "edge_port_edit_failed"
|
||||
_fail("edge_port_edits", str(detail), status=int(exc.status_code or 400))
|
||||
|
||||
|
||||
def _upsert_edge_style(
|
||||
db: Session,
|
||||
view_id: str,
|
||||
item: ViewEdgeStyleItem,
|
||||
*,
|
||||
now,
|
||||
) -> None:
|
||||
eid = str(item.fabric_edge_id or "").strip()
|
||||
if not eid or db.get(TopoFabricEdge, eid) is None:
|
||||
_fail("edge_styles", "fabric_edge_not_found", status=404)
|
||||
color = str(item.stroke_color or "").strip()
|
||||
if color and not _HEX_COLOR_RE.match(color):
|
||||
_fail("edge_styles", "invalid_stroke_color")
|
||||
width = int(item.stroke_width or 0)
|
||||
if width < 0 or width > 12:
|
||||
_fail("edge_styles", "invalid_stroke_width")
|
||||
style = str(item.line_style or "").strip().lower()
|
||||
if style not in _LINE_STYLES:
|
||||
_fail("edge_styles", "invalid_line_style")
|
||||
row = (
|
||||
db.query(TopoViewEdgeStyle)
|
||||
.filter(
|
||||
TopoViewEdgeStyle.view_id == view_id,
|
||||
TopoViewEdgeStyle.fabric_edge_id == eid,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
db.add(
|
||||
TopoViewEdgeStyle(
|
||||
id=uuid4().hex,
|
||||
view_id=view_id,
|
||||
fabric_edge_id=eid,
|
||||
stroke_color=color,
|
||||
stroke_width=width,
|
||||
line_style=style,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
else:
|
||||
row.stroke_color = color
|
||||
row.stroke_width = width
|
||||
row.line_style = style
|
||||
row.updated_at = now
|
||||
|
||||
|
||||
def _apply_edge_styles(
|
||||
db: Session,
|
||||
view_id: str,
|
||||
styles: list[ViewEdgeStyleItem],
|
||||
*,
|
||||
edge_id_map: dict[str, str],
|
||||
deleted: set[str],
|
||||
) -> None:
|
||||
now = _utcnow()
|
||||
for item in styles:
|
||||
eid = str(item.fabric_edge_id or "").strip()
|
||||
if eid in deleted:
|
||||
continue
|
||||
# Styles on a just-created local edge are keyed by its client id.
|
||||
if eid in edge_id_map:
|
||||
item = item.model_copy(update={"fabric_edge_id": edge_id_map[eid]})
|
||||
_upsert_edge_style(db, view_id, item, now=now)
|
||||
|
||||
|
||||
def _apply_node_adds(
|
||||
db: Session,
|
||||
view,
|
||||
fabric_ids: list[str],
|
||||
*,
|
||||
max_nodes: int,
|
||||
existing: dict[str, TopoViewNode],
|
||||
) -> None:
|
||||
if is_world_flat_view(view):
|
||||
_fail("node_adds", "world_map_no_direct_nes")
|
||||
ids = [str(x).strip() for x in (fabric_ids or []) if str(x).strip()]
|
||||
if not ids:
|
||||
return
|
||||
if len(existing) >= max_nodes:
|
||||
_fail("node_adds", "membership_max_nodes")
|
||||
now = _utcnow()
|
||||
room = max(0, max_nodes - len(existing))
|
||||
added = 0
|
||||
for fid in ids:
|
||||
if fid in existing:
|
||||
continue
|
||||
if is_region_canvas_node_id(fid):
|
||||
continue
|
||||
if db.get(TopoFabricNode, fid) is None:
|
||||
_fail("node_adds", f"fabric_node_not_found:{fid}")
|
||||
if added >= room:
|
||||
_fail("node_adds", "membership_max_nodes")
|
||||
row = TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_node_id=fid,
|
||||
x=40.0,
|
||||
y=40.0,
|
||||
label="",
|
||||
locked=False,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
existing[fid] = row
|
||||
added += 1
|
||||
if added:
|
||||
view.updated_at = now
|
||||
|
||||
|
||||
def _apply_node_removes(
|
||||
db: Session,
|
||||
view,
|
||||
fabric_ids: list[str],
|
||||
*,
|
||||
existing: dict[str, TopoViewNode],
|
||||
) -> None:
|
||||
ids = [str(x).strip() for x in (fabric_ids or []) if str(x).strip()]
|
||||
ids = [x for x in ids if x and not is_region_canvas_node_id(x)]
|
||||
if not ids:
|
||||
return
|
||||
db.query(TopoViewNode).filter(
|
||||
TopoViewNode.view_id == view.id,
|
||||
TopoViewNode.fabric_node_id.in_(ids),
|
||||
).delete(synchronize_session=False)
|
||||
for fid in ids:
|
||||
existing.pop(fid, None)
|
||||
view.updated_at = _utcnow()
|
||||
|
||||
|
||||
def _apply_positions(
|
||||
db: Session,
|
||||
view,
|
||||
positions: list[ViewNodeIn],
|
||||
*,
|
||||
existing: dict[str, TopoViewNode],
|
||||
) -> int:
|
||||
"""Apply positions in-session. Returns skipped_locked count."""
|
||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
_fail("positions", "too_many_positions")
|
||||
now = _utcnow()
|
||||
skipped_locked = 0
|
||||
updated = 0
|
||||
fabric_ids = [
|
||||
str(p.fabric_node_id or "").strip()
|
||||
for p in positions
|
||||
if str(p.fabric_node_id or "").strip()
|
||||
and not is_region_canvas_node_id(str(p.fabric_node_id or "").strip())
|
||||
]
|
||||
alive = (
|
||||
{str(r[0]) for r in db.query(TopoFabricNode.id).filter(TopoFabricNode.id.in_(fabric_ids))}
|
||||
if fabric_ids
|
||||
else set()
|
||||
)
|
||||
for p in positions:
|
||||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
if not is_region_canvas_node_id(fid) and fid not in alive and fid not in existing:
|
||||
# Missing fabric: skip rather than fail mid-commit when node was just removed.
|
||||
continue
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
# Fabric NEs are placed via node_adds; only synthesized child-region icons are created here.
|
||||
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
|
||||
if row is None:
|
||||
continue
|
||||
existing[fid] = row
|
||||
if row.locked and p.locked is not False:
|
||||
skipped_locked += 1
|
||||
continue
|
||||
row.x = float(p.x or 0)
|
||||
row.y = float(p.y or 0)
|
||||
if p.label is not None:
|
||||
row.label = str(p.label or "")[:256]
|
||||
if p.locked is not None:
|
||||
row.locked = bool(p.locked)
|
||||
row.updated_at = now
|
||||
updated += 1
|
||||
if updated or skipped_locked:
|
||||
view.updated_at = now
|
||||
return skipped_locked
|
||||
|
||||
|
||||
def commit_view_changes(db: Session, view_id: str, body: ViewCommitIn) -> ViewCommitOut:
|
||||
"""Apply canvas mutations atomically; return one graph + client→server edge map."""
|
||||
view = _get_view_or_404(db, view_id)
|
||||
mem = _membership_for_view(view)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
existing = {
|
||||
vn.fabric_node_id: vn
|
||||
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||
}
|
||||
edge_id_map: dict[str, str] = {}
|
||||
skipped_locked = 0
|
||||
|
||||
try:
|
||||
_apply_edge_creates(db, list(body.edge_creates or []), edge_id_map)
|
||||
deleted = _apply_edge_deletes(db, list(body.edge_deletes or []))
|
||||
_apply_edge_port_edits(db, list(body.edge_port_edits or []), deleted=deleted)
|
||||
_apply_edge_styles(
|
||||
db,
|
||||
view.id,
|
||||
list(body.edge_styles or []),
|
||||
edge_id_map=edge_id_map,
|
||||
deleted=deleted,
|
||||
)
|
||||
_apply_node_adds(
|
||||
db,
|
||||
view,
|
||||
list(body.node_adds or []),
|
||||
max_nodes=max_nodes,
|
||||
existing=existing,
|
||||
)
|
||||
_apply_node_removes(
|
||||
db,
|
||||
view,
|
||||
list(body.node_removes or []),
|
||||
existing=existing,
|
||||
)
|
||||
# Re-load placements after add/remove so positions see current rows.
|
||||
# Flush first: sessions run with autoflush=False, so new placements would be invisible.
|
||||
db.flush()
|
||||
existing = {
|
||||
vn.fabric_node_id: vn
|
||||
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||
}
|
||||
skipped_locked = _apply_positions(
|
||||
db,
|
||||
view,
|
||||
list(body.positions or []),
|
||||
existing=existing,
|
||||
)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
_fail("commit", str(exc)[:500], status=500)
|
||||
|
||||
try:
|
||||
refresh_fabric_stats(db)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
graph: TopologyViewGraphOut = get_view_graph(db, view.id)
|
||||
return ViewCommitOut(
|
||||
ok=True,
|
||||
view_id=view.id,
|
||||
edge_id_map=edge_id_map,
|
||||
skipped_locked=skipped_locked,
|
||||
graph=graph,
|
||||
)
|
||||
|
||||
|
||||
def normalize_edge_style_items(body: Any) -> list[ViewEdgeStyleItem]:
|
||||
"""Accept legacy single-edge body or batch ``items``."""
|
||||
items = list(getattr(body, "items", None) or [])
|
||||
if items:
|
||||
return [
|
||||
ViewEdgeStyleItem(
|
||||
fabric_edge_id=str(i.fabric_edge_id or ""),
|
||||
stroke_color=str(i.stroke_color or ""),
|
||||
stroke_width=int(i.stroke_width or 0),
|
||||
line_style=str(i.line_style or ""),
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
fid = str(getattr(body, "fabric_edge_id", "") or "").strip()
|
||||
if not fid:
|
||||
return []
|
||||
return [
|
||||
ViewEdgeStyleItem(
|
||||
fabric_edge_id=fid,
|
||||
stroke_color=str(getattr(body, "stroke_color", "") or ""),
|
||||
stroke_width=int(getattr(body, "stroke_width", 0) or 0),
|
||||
line_style=str(getattr(body, "line_style", "") or ""),
|
||||
)
|
||||
]
|
||||
|
|
@ -157,7 +157,12 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
# Save/mutate return_graph must use the same UME synthetic graph as GET.
|
||||
if is_ume_canvas_view(view):
|
||||
return get_ume_canvas_graph(db, view_id)
|
||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||
vnodes = (
|
||||
db.query(TopoViewNode)
|
||||
.filter(TopoViewNode.view_id == view.id)
|
||||
.order_by(TopoViewNode.created_at.asc(), TopoViewNode.id.asc())
|
||||
.all()
|
||||
)
|
||||
truncated = False
|
||||
reason = ""
|
||||
if len(vnodes) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
|
|
@ -169,6 +174,19 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all()
|
||||
} if fids else {}
|
||||
managed_by_id, ume_by_id = _batch_ne_lookups(db, fabric_nodes)
|
||||
region_folder_ids = [
|
||||
region_folder_id_from_node(vn.fabric_node_id)
|
||||
for vn in vnodes
|
||||
if is_region_canvas_node_id(vn.fabric_node_id)
|
||||
]
|
||||
folders_by_id = {
|
||||
f.id: f
|
||||
for f in (
|
||||
db.query(TopoFolder).filter(TopoFolder.id.in_(region_folder_ids)).all()
|
||||
if region_folder_ids
|
||||
else []
|
||||
)
|
||||
}
|
||||
filt = dict(view.filter or {})
|
||||
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
||||
status = str(filt.get("status") or "").strip().lower()
|
||||
|
|
@ -176,7 +194,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
region_ids_seen: set[str] = set()
|
||||
for vn in vnodes:
|
||||
if is_region_canvas_node_id(vn.fabric_node_id):
|
||||
folder = db.get(TopoFolder, region_folder_id_from_node(vn.fabric_node_id))
|
||||
folder = folders_by_id.get(region_folder_id_from_node(vn.fabric_node_id))
|
||||
if folder is None or str(folder.kind or "") != "region":
|
||||
continue
|
||||
region_ids_seen.add(folder.id)
|
||||
|
|
@ -187,12 +205,13 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
x=float(vn.x or 0),
|
||||
y=float(vn.y or 0),
|
||||
locked=bool(vn.locked),
|
||||
label=str(vn.label or folder.name or ""),
|
||||
label_override=str(vn.label or ""),
|
||||
)
|
||||
)
|
||||
continue
|
||||
fn = fabric_nodes.get(vn.fabric_node_id)
|
||||
label = (vn.label or "").strip()
|
||||
label_override = str(vn.label or "")
|
||||
label = label_override.strip()
|
||||
if not label and fn is not None:
|
||||
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
|
||||
connect_status = ""
|
||||
|
|
@ -216,6 +235,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
managed_ne_id=(fn.managed_ne_id if fn else "") or "",
|
||||
ume_ne_id=(fn.ume_ne_id if fn else "") or "",
|
||||
label=label,
|
||||
label_override=label_override[:256],
|
||||
x=float(vn.x or 0),
|
||||
y=float(vn.y or 0),
|
||||
locked=bool(vn.locked),
|
||||
|
|
@ -348,22 +368,31 @@ def _outside_peers_for_view(
|
|||
member_ids: set[str],
|
||||
layer: str,
|
||||
limit: int = 50,
|
||||
edge_scan_limit: int = 5000,
|
||||
) -> list[dict[str, str]]:
|
||||
if not member_ids:
|
||||
return []
|
||||
members = list(member_ids)
|
||||
# Exclude edges whose both ends are already on the view so dense canvases
|
||||
# do not burn the scan budget before any external neighbor is found.
|
||||
both_internal = and_(
|
||||
TopoFabricEdge.a_node_id.in_(members),
|
||||
TopoFabricEdge.b_node_id.in_(members),
|
||||
)
|
||||
edges = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
TopoFabricEdge.layer == layer,
|
||||
or_(
|
||||
TopoFabricEdge.a_node_id.in_(list(member_ids)),
|
||||
TopoFabricEdge.b_node_id.in_(list(member_ids)),
|
||||
TopoFabricEdge.a_node_id.in_(members),
|
||||
TopoFabricEdge.b_node_id.in_(members),
|
||||
),
|
||||
~both_internal,
|
||||
)
|
||||
.limit(5000)
|
||||
.limit(max(1, int(edge_scan_limit or 5000)))
|
||||
.all()
|
||||
)
|
||||
out: list[dict[str, str]] = []
|
||||
peer_via: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for e in edges:
|
||||
for peer_id, local_id in ((e.b_node_id, e.a_node_id), (e.a_node_id, e.b_node_id)):
|
||||
|
|
@ -372,17 +401,29 @@ def _outside_peers_for_view(
|
|||
if peer_id in seen:
|
||||
continue
|
||||
seen.add(peer_id)
|
||||
fn = db.get(TopoFabricNode, peer_id)
|
||||
out.append(
|
||||
{
|
||||
"fabric_node_id": peer_id,
|
||||
"name": (fn.name if fn else "") or "",
|
||||
"ip": (fn.ip if fn else "") or "",
|
||||
"via_node_id": local_id,
|
||||
}
|
||||
)
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
peer_via.append((peer_id, local_id))
|
||||
if len(peer_via) >= limit:
|
||||
break
|
||||
if len(peer_via) >= limit:
|
||||
break
|
||||
if not peer_via:
|
||||
return []
|
||||
peer_ids = [p for p, _ in peer_via]
|
||||
peers = {
|
||||
n.id: n
|
||||
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(peer_ids)).all()
|
||||
}
|
||||
out: list[dict[str, str]] = []
|
||||
for peer_id, local_id in peer_via:
|
||||
fn = peers.get(peer_id)
|
||||
out.append(
|
||||
{
|
||||
"fabric_node_id": peer_id,
|
||||
"name": (fn.name if fn else "") or "",
|
||||
"ip": (fn.ip if fn else "") or "",
|
||||
"via_node_id": local_id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -646,6 +687,7 @@ def patch_view_positions(
|
|||
layout = str(body.layout or "").strip().lower()
|
||||
updated = 0
|
||||
skipped_locked = 0
|
||||
skipped_missing = 0
|
||||
matched = 0
|
||||
|
||||
existing = {
|
||||
|
|
@ -706,45 +748,54 @@ def patch_view_positions(
|
|||
row.updated_at = now
|
||||
updated += 1
|
||||
else:
|
||||
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
|
||||
|
||||
positions = list(body.positions or [])
|
||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
raise HTTPException(status_code=400, detail="too_many_positions")
|
||||
matched = len(positions)
|
||||
# One IN query instead of per-node db.get (N+1).
|
||||
check_ids = [
|
||||
str(p.fabric_node_id or "").strip()
|
||||
for p in positions
|
||||
if str(p.fabric_node_id or "").strip()
|
||||
and not is_region_canvas_node_id(str(p.fabric_node_id or "").strip())
|
||||
]
|
||||
fabric_alive: set[str] = set()
|
||||
if check_ids:
|
||||
fabric_alive = {
|
||||
str(x[0])
|
||||
for x in db.query(TopoFabricNode.id)
|
||||
.filter(TopoFabricNode.id.in_(check_ids))
|
||||
.all()
|
||||
}
|
||||
for p in positions:
|
||||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
from .topology_region_canvas import is_region_canvas_node_id
|
||||
|
||||
if not is_region_canvas_node_id(fid) and db.get(TopoFabricNode, fid) is None:
|
||||
if not is_region_canvas_node_id(fid) and fid not in fabric_alive:
|
||||
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
row = TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_node_id=fid,
|
||||
x=float(p.x or 0),
|
||||
y=float(p.y or 0),
|
||||
label=str(p.label or "")[:256],
|
||||
locked=bool(p.locked),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
existing[fid] = row
|
||||
updated += 1
|
||||
else:
|
||||
if row.locked and bool(p.locked):
|
||||
skipped_locked += 1
|
||||
# Child-region icons may be synthesized without a placement row; persist those.
|
||||
# Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes).
|
||||
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
|
||||
if row is None:
|
||||
skipped_missing += 1
|
||||
continue
|
||||
row.x = float(p.x or 0)
|
||||
row.y = float(p.y or 0)
|
||||
if p.label is not None:
|
||||
row.label = str(p.label or "")[:256]
|
||||
existing[fid] = row
|
||||
# Locked rows stay put unless the client explicitly unlocks (locked=False).
|
||||
if row.locked and p.locked is not False:
|
||||
skipped_locked += 1
|
||||
continue
|
||||
row.x = float(p.x or 0)
|
||||
row.y = float(p.y or 0)
|
||||
if p.label is not None:
|
||||
row.label = str(p.label or "")[:256]
|
||||
if p.locked is not None:
|
||||
row.locked = bool(p.locked)
|
||||
row.updated_at = now
|
||||
updated += 1
|
||||
row.updated_at = now
|
||||
updated += 1
|
||||
|
||||
view.updated_at = now
|
||||
db.commit()
|
||||
|
|
@ -756,6 +807,7 @@ def patch_view_positions(
|
|||
matched=matched,
|
||||
updated=updated,
|
||||
skipped_locked=skipped_locked,
|
||||
skipped_missing=skipped_missing,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1353,42 +1405,48 @@ _LINE_STYLES = {"", "solid", "dashed", "dotted"}
|
|||
def patch_view_edge_style(
|
||||
db: Session, view_id: str, body: ViewEdgeStylePatch
|
||||
) -> TopologyViewGraphOut:
|
||||
from .topology_views_commit import normalize_edge_style_items
|
||||
|
||||
view = _get_view_or_404(db, view_id)
|
||||
eid = str(body.fabric_edge_id or "").strip()
|
||||
if not eid or db.get(TopoFabricEdge, eid) is None:
|
||||
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
|
||||
color = str(body.stroke_color or "").strip()
|
||||
if color and not _HEX_COLOR_RE.match(color):
|
||||
raise HTTPException(status_code=400, detail="invalid_stroke_color")
|
||||
width = int(body.stroke_width or 0)
|
||||
if width < 0 or width > 12:
|
||||
raise HTTPException(status_code=400, detail="invalid_stroke_width")
|
||||
style = str(body.line_style or "").strip().lower()
|
||||
if style not in _LINE_STYLES:
|
||||
raise HTTPException(status_code=400, detail="invalid_line_style")
|
||||
items = normalize_edge_style_items(body)
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="fabric_edge_id_required")
|
||||
now = _utcnow()
|
||||
row = (
|
||||
db.query(TopoViewEdgeStyle)
|
||||
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
row = TopoViewEdgeStyle(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_edge_id=eid,
|
||||
stroke_color=color,
|
||||
stroke_width=width,
|
||||
line_style=style,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
for item in items:
|
||||
eid = str(item.fabric_edge_id or "").strip()
|
||||
if not eid or db.get(TopoFabricEdge, eid) is None:
|
||||
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
|
||||
color = str(item.stroke_color or "").strip()
|
||||
if color and not _HEX_COLOR_RE.match(color):
|
||||
raise HTTPException(status_code=400, detail="invalid_stroke_color")
|
||||
width = int(item.stroke_width or 0)
|
||||
if width < 0 or width > 12:
|
||||
raise HTTPException(status_code=400, detail="invalid_stroke_width")
|
||||
style = str(item.line_style or "").strip().lower()
|
||||
if style not in _LINE_STYLES:
|
||||
raise HTTPException(status_code=400, detail="invalid_line_style")
|
||||
row = (
|
||||
db.query(TopoViewEdgeStyle)
|
||||
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
|
||||
.one_or_none()
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.stroke_color = color
|
||||
row.stroke_width = width
|
||||
row.line_style = style
|
||||
row.updated_at = now
|
||||
if row is None:
|
||||
row = TopoViewEdgeStyle(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_edge_id=eid,
|
||||
stroke_color=color,
|
||||
stroke_width=width,
|
||||
line_style=style,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.stroke_color = color
|
||||
row.stroke_width = width
|
||||
row.line_style = style
|
||||
row.updated_at = now
|
||||
view.updated_at = now
|
||||
db.commit()
|
||||
return get_view_graph(db, view.id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue