netx/netx_api/topology_region_canvas.py
oliver f301544b99 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>
2026-10-12 22:11:22 +08:00

265 lines
8.1 KiB
Python

"""Child-region icons on a parent canvas (folder === canvas drill-down).
Synthetic placements use fabric_node_id ``region:<folder_id>`` on the parent's
TopoViewNode rows — same shape as UME level region nodes, persisted so drag/save works.
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from .models import TopoFolder, TopoView, TopoViewNode
from .topology_common import _utcnow
from .topology_membership import VIEW_KIND_PHYSICAL, normalize_view_kind
from .topology_schemas import ViewNodeOut
REGION_NODE_PREFIX = "region:"
def is_region_canvas_node_id(fabric_node_id: str | None) -> bool:
return str(fabric_node_id or "").startswith(REGION_NODE_PREFIX)
def region_folder_id_from_node(fabric_node_id: str) -> str:
return str(fabric_node_id or "")[len(REGION_NODE_PREFIX) :]
def region_canvas_node_id(folder_id: str) -> str:
return f"{REGION_NODE_PREFIX}{folder_id}"
def _is_world_flat(view: TopoView) -> bool:
filt = dict(view.filter or {})
if filt.get("world_flat"):
return True
from .ume_topology_world import is_world_flat_view_name
return is_world_flat_view_name(str(view.name or "").strip())
def primary_canvas_view(db: Session, folder_id: str) -> TopoView | None:
"""Parent canvas used for child-region icons (skip flat world map)."""
fid = str(folder_id or "").strip()
if not fid:
return None
views = (
db.query(TopoView)
.filter(TopoView.folder_id == fid)
.order_by(TopoView.sort_order.asc(), TopoView.created_at.asc())
.all()
)
usable = [v for v in views if not _is_world_flat(v)]
if not usable:
return None
for v in usable:
filt = dict(v.filter or {})
if filt.get("ume_level") or filt.get("world"):
return v
for v in usable:
if normalize_view_kind(v.kind) == VIEW_KIND_PHYSICAL:
return v
return usable[0]
def _child_primary_view(db: Session, folder_id: str) -> TopoView | None:
return primary_canvas_view(db, folder_id)
def _default_region_xy(db: Session, view_id: str) -> tuple[float, float]:
rows = db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).all()
if not rows:
return 160.0, 160.0
max_x = max(float(r.x or 0) for r in rows)
ys = [float(r.y or 0) for r in rows]
avg_y = sum(ys) / len(ys) if ys else 160.0
return max_x + 220.0, avg_y
def place_child_region_on_parent_canvas(
db: Session,
parent: TopoFolder,
child: TopoFolder,
*,
x: float | None = None,
y: float | None = None,
) -> TopoViewNode | None:
"""Put a region building icon on the parent's canvas. No-op for root parent."""
if parent is None or child is None:
return None
if str(parent.kind or "") != "region":
return None
view = primary_canvas_view(db, parent.id)
if view is None:
return None
nid = region_canvas_node_id(child.id)
existing = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == nid)
.first()
)
if existing is not None:
if child.name and existing.label != child.name:
existing.label = str(child.name)[:256]
existing.updated_at = _utcnow()
return existing
px, py = _default_region_xy(db, view.id) if x is None or y is None else (float(x), float(y))
now = _utcnow()
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=nid,
x=px,
y=py,
label=str(child.name or "")[:256],
locked=False,
created_at=now,
updated_at=now,
)
db.add(row)
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)
return (
db.query(TopoViewNode)
.filter(TopoViewNode.fabric_node_id == nid)
.delete(synchronize_session=False)
)
def region_node_out(
db: Session,
*,
folder: TopoFolder,
x: float,
y: float,
locked: bool = False,
label: str = "",
label_override: str | None = None,
) -> ViewNodeOut:
child_view = _child_primary_view(db, folder.id)
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder.id, TopoFolder.kind == "region")
.count()
)
# node_count: prefer view membership count when available
node_count = 0
if child_view is not None:
node_count = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == child_view.id)
.count()
)
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),
name=str(folder.name or "")[:256],
ip="",
vendor="",
device_type="region",
kind="region",
folder_id=folder.id,
view_id=child_view.id if child_view else "",
node_count=int(node_count or kids or 0),
)
def _is_ume_managed_folder(folder: TopoFolder) -> bool:
"""UME World / drill / SBN folders are drawn by ume_topology_world_graph, not here."""
ext = str(getattr(folder, "external_ref", None) or "").strip()
return bool(ext)
def child_region_nodes_for_view(db: Session, view: TopoView) -> list[ViewNodeOut]:
"""Manual child regions as canvas icons (skip UME-synced SBN folders)."""
folder_id = str(view.folder_id or "").strip()
if not folder_id or _is_world_flat(view):
return []
children = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder_id, TopoFolder.kind == "region")
.order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc())
.all()
)
children = [c for c in children if not _is_ume_managed_folder(c)]
if not children:
return []
placements = {
vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
if is_region_canvas_node_id(vn.fabric_node_id)
}
out: list[ViewNodeOut] = []
auto_x, auto_y = _default_region_xy(db, view.id)
for i, child in enumerate(children):
nid = region_canvas_node_id(child.id)
vn = placements.get(nid)
if vn is not None:
out.append(
region_node_out(
db,
folder=child,
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
label=str(vn.label or child.name or ""),
)
)
else:
out.append(
region_node_out(
db,
folder=child,
x=auto_x + i * 40,
y=auto_y + i * 20,
locked=False,
label=str(child.name or ""),
)
)
return out