Unify UME World as hex nav with conditional flat world map.

Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 00:51:08 +08:00
parent 5d705fa7bb
commit 1ef43fdcd4
30 changed files with 4389 additions and 371 deletions

View file

@ -0,0 +1,523 @@
"""UME hierarchical level graph + flat world viewport graph."""
from __future__ import annotations
from collections import defaultdict
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeTopoNode
from .topology_common import (
VIEW_GRAPH_EDGE_HARD_CAP,
_EDGE_STATUS_MISSING,
_EDGE_STATUS_MISSING_COMPAT,
_normalize_edge_status,
)
from .topology_schemas import (
TopologyViewGraphOut,
ViewEdgeOut,
ViewNodeOut,
)
from .topology_views_tree import _get_view_or_404, _view_out
from .ume_topology_world import (
folder_bbox,
is_ume_canvas_view,
is_ume_level_view,
is_world_flat_view,
is_world_view,
)
WORLD_NODE_SOFT_CAP = 8000
def apply_persisted_view_positions(
db: Session, view_id: str, nodes: list[ViewNodeOut]
) -> list[ViewNodeOut]:
"""Overlay drag/save positions from TopoViewNode onto a synthetic UME graph.
UME level canvases are built from dock coords; user moves persist on the view
membership table and must win on reload (and on save return_graph).
"""
vid = str(view_id or "").strip()
if not vid or not nodes:
return nodes
rows = db.query(TopoViewNode).filter(TopoViewNode.view_id == vid).all()
if not rows:
return nodes
overrides = {str(vn.fabric_node_id): vn for vn in rows}
out: list[ViewNodeOut] = []
for n in nodes:
ov = overrides.get(str(n.fabric_node_id))
if ov is None:
out.append(n)
continue
# Keep UME-built labels (e.g. region NE counts); only persist geometry.
out.append(
n.model_copy(
update={
"x": float(ov.x or 0),
"y": float(ov.y or 0),
"locked": bool(ov.locked),
}
)
)
return out
def get_ume_canvas_graph(
db: Session,
view_id: str,
*,
min_x: float | None = None,
max_x: float | None = None,
min_y: float | None = None,
max_y: float | None = None,
sbn_id: str = "",
folder_id: str = "",
lod: str = "auto",
status: str = "active",
) -> TopologyViewGraphOut:
view = _get_view_or_404(db, view_id)
if not is_ume_canvas_view(view):
raise HTTPException(status_code=400, detail="not_a_ume_canvas_view")
if is_world_flat_view(view) or (
bool((view.filter or {}).get("world")) and not is_ume_level_view(view)
):
return get_flat_view_graph(
db,
view,
min_x=min_x,
max_x=max_x,
min_y=min_y,
max_y=max_y,
sbn_id=sbn_id,
folder_id=folder_id,
status=status,
)
return get_level_view_graph(db, view, status=status)
# Back-compat name used by router
def get_world_view_graph(
db: Session,
view_id: str,
**kwargs: Any,
) -> TopologyViewGraphOut:
return get_ume_canvas_graph(db, view_id, **kwargs)
def get_level_view_graph(
db: Session,
view: TopoView,
*,
status: str = "active",
) -> TopologyViewGraphOut:
"""One UME canvas level: direct child SBNs (regions) + direct child MEs."""
filt = dict(view.filter or {})
parent_key = str(filt.get("parent") or "").strip()
sbn_id = str(filt.get("sbn_id") or "").strip()
if parent_key == "md" or is_world_view(view):
# Root: SBN whose parent is not another SBN (+ rare direct MEs under MD).
sbn_ids = {
str(n.node_id)
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(n.node_id or "").strip()
}
child_sbns = [
n
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(n.parent_node or "").strip() not in sbn_ids
]
child_mes = [
n
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all()
if str(n.parent_node or "").strip() not in sbn_ids
]
level_parent = "" # MD virtual
else:
if not sbn_id and view.folder_id:
folder = db.get(TopoFolder, view.folder_id)
sbn_id = str(getattr(folder, "external_ref", None) or "").strip()
if sbn_id == "ume:world":
sbn_id = ""
if not sbn_id:
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
level_parent = sbn_id
child_sbns = (
db.query(UmeTopoNode)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_SBN",
UmeTopoNode.parent_node == level_parent,
)
.all()
)
child_mes = (
db.query(UmeTopoNode)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node == level_parent,
)
.all()
)
# Folder / view lookup for region drill targets
folders_by_ref: dict[str, TopoFolder] = {
str(f.external_ref): f
for f in db.query(TopoFolder)
.filter(TopoFolder.external_ref.isnot(None), TopoFolder.external_ref != "")
.all()
if str(f.external_ref or "").strip() and not str(f.external_ref).startswith("ume:")
}
level_view_by_sbn: dict[str, TopoView] = {}
for v in db.query(TopoView).all():
vf = dict(v.filter or {})
if vf.get("ume_level") and str(vf.get("sbn_id") or "").strip():
level_view_by_sbn[str(vf["sbn_id"])] = v
# Descendants of each direct child SBN (for logical edge lift + counts)
all_sbns = {
str(n.node_id): n
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(n.node_id or "").strip()
}
sbn_children: dict[str, list[str]] = defaultdict(list)
for sid, node in all_sbns.items():
p = str(node.parent_node or "").strip()
if p:
sbn_children[p].append(sid)
def _descendants(root_sid: str) -> set[str]:
out = {root_sid}
stack = [root_sid]
while stack:
cur = stack.pop()
for cid in sbn_children.get(cur, []):
if cid not in out:
out.add(cid)
stack.append(cid)
return out
child_sbn_ids = [str(s.node_id) for s in child_sbns if str(s.node_id or "").strip()]
region_desc: dict[str, set[str]] = {sid: _descendants(sid) for sid in child_sbn_ids}
# ME → which direct child region (if under a child SBN subtree)
me_to_region: dict[str, str] = {}
me_uid_to_topo: dict[str, UmeTopoNode] = {}
for tn in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all():
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
me_uid_to_topo[uid] = tn
parent = str(tn.parent_node or "").strip()
for rid, desc in region_desc.items():
if parent in desc:
me_to_region[uid] = rid
break
fabric_by_ume: dict[str, TopoFabricNode] = {
str(n.ume_ne_id): n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.isnot(None)).all()
if str(n.ume_ne_id or "").strip()
}
nodes_out: list[ViewNodeOut] = []
# Region nodes
region_node_ids: dict[str, str] = {} # sbn_id -> fabric_node_id used in graph
for sbn in child_sbns:
sid = str(sbn.node_id or "").strip()
if not sid:
continue
folder = folders_by_ref.get(sid)
child_view = level_view_by_sbn.get(sid)
# Count MEs under this region tree
n_me = sum(1 for uid, rid in me_to_region.items() if rid == sid)
nid = f"region:{sid}"
region_node_ids[sid] = nid
label = (sbn.user_label or (folder.name if folder else sid) or sid)[:256]
if n_me:
label = f"{label} ({n_me})"
nodes_out.append(
ViewNodeOut(
fabric_node_id=nid,
managed_ne_id="",
ume_ne_id=sid,
label=label,
x=float(sbn.x_pos or 0),
y=float(sbn.y_pos or 0),
locked=False,
name=(sbn.user_label or "")[:256],
ip="",
vendor="SBN",
device_type="region",
kind="region",
folder_id=folder.id if folder else "",
view_id=child_view.id if child_view else "",
node_count=n_me,
)
)
# Direct ME nodes (local coords)
direct_me_fids: set[str] = set()
me_fid_by_uid: dict[str, str] = {}
for tn in child_mes:
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
fn = fabric_by_ume.get(uid)
if fn is None:
continue
direct_me_fids.add(fn.id)
me_fid_by_uid[uid] = fn.id
lx = float(tn.x_pos) if tn.x_pos is not None else float(fn.attrs or {}).get("ume_local_x") or 0.0
ly = float(tn.y_pos) if tn.y_pos is not None else float(fn.attrs or {}).get("ume_local_y") or 0.0
nodes_out.append(
ViewNodeOut(
fabric_node_id=fn.id,
managed_ne_id=str(fn.managed_ne_id or ""),
ume_ne_id=uid,
label=(fn.name or fn.ip or uid)[:256],
x=lx,
y=ly,
locked=False,
name=fn.name or "",
ip=fn.ip or "",
vendor=fn.vendor or "",
device_type=fn.device_type or "",
kind="ne",
)
)
# Edges: physical among direct MEs; logical lifts across regions
st = str(status or "active").strip().lower() or "active"
st_norm = _normalize_edge_status(st)
eq = db.query(TopoFabricEdge).filter(TopoFabricEdge.layer == "physical")
if st_norm == _EDGE_STATUS_MISSING:
eq = eq.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT)))
else:
eq = eq.filter(TopoFabricEdge.status == st_norm)
# Map fabric id → ume ne id
fid_to_uid = {fn.id: uid for uid, fn in fabric_by_ume.items()}
def _endpoint_on_canvas(fid: str) -> str | None:
"""Return canvas node id for a fabric endpoint, or None if outside this level."""
if fid in direct_me_fids:
return fid
uid = fid_to_uid.get(fid, "")
rid = me_to_region.get(uid, "")
if rid and rid in region_node_ids:
return region_node_ids[rid]
return None
physical: list[ViewEdgeOut] = []
logical_count: dict[tuple[str, str], int] = defaultdict(int)
for e in eq.limit(VIEW_GRAPH_EDGE_HARD_CAP * 20).all():
a = _endpoint_on_canvas(e.a_node_id)
b = _endpoint_on_canvas(e.b_node_id)
if not a or not b or a == b:
continue
# Both direct MEs → physical
if a in direct_me_fids and b in direct_me_fids:
physical.append(
ViewEdgeOut(
id=e.id,
a_node_id=a,
b_node_id=b,
a_port=e.a_port or "",
b_port=e.b_port or "",
source=e.source or "lldp",
status=e.status or "active",
layer=e.layer or "physical",
discovered_at=e.discovered_at,
)
)
else:
key = (a, b) if a < b else (b, a)
logical_count[key] += 1
edges_out = physical[:VIEW_GRAPH_EDGE_HARD_CAP]
truncated = len(physical) > VIEW_GRAPH_EDGE_HARD_CAP
for (a, b), cnt in logical_count.items():
edges_out.append(
ViewEdgeOut(
id=f"logical:{a}:{b}",
a_node_id=a,
b_node_id=b,
a_port=str(cnt),
b_port="",
source="ume",
status="active",
layer="logical",
)
)
# Manual nested regions (新建子区域) on this UME canvas.
from .topology_region_canvas import child_region_nodes_for_view
seen_folder = {str(n.folder_id or "") for n in nodes_out if n.kind == "region"}
for rn in child_region_nodes_for_view(db, view):
fid = str(rn.folder_id or "").strip()
if fid and fid not in seen_folder:
nodes_out.append(rn)
seen_folder.add(fid)
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
return TopologyViewGraphOut(
view=_view_out(view),
nodes=nodes_out,
edges=edges_out,
truncated=truncated,
truncate_reason="too_many_edges" if truncated else "",
)
def get_flat_view_graph(
db: Session,
view: TopoView,
*,
min_x: float | None = None,
max_x: float | None = None,
min_y: float | None = None,
max_y: float | None = None,
sbn_id: str = "",
folder_id: str = "",
status: str = "active",
) -> TopologyViewGraphOut:
"""All NEs using composed world_x/y — no region nodes."""
region_folder_ids: set[str] | None = None
if folder_id.strip():
bbox = folder_bbox(db, folder_id.strip())
folder = db.get(TopoFolder, folder_id.strip())
if folder is not None:
all_folders = db.query(TopoFolder).all()
children: dict[str | None, list[str]] = {}
for f in all_folders:
children.setdefault(f.parent_id, []).append(f.id)
region_folder_ids = {folder.id}
stack = [folder.id]
while stack:
cur = stack.pop()
for cid in children.get(cur, []):
if cid not in region_folder_ids:
region_folder_ids.add(cid)
stack.append(cid)
if bbox and min_x is None:
pad_x = max(50.0, (bbox["max_x"] - bbox["min_x"]) * 0.05)
pad_y = max(50.0, (bbox["max_y"] - bbox["min_y"]) * 0.05)
min_x = float(bbox["min_x"]) - pad_x
max_x = float(bbox["max_x"]) + pad_x
min_y = float(bbox["min_y"]) - pad_y
max_y = float(bbox["max_y"]) + pad_y
extent = (
db.query(TopoFabricNode)
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
.all()
)
if not extent:
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
full_min_x = min(float(n.world_x) for n in extent)
full_max_x = max(float(n.world_x) for n in extent)
full_min_y = min(float(n.world_y) for n in extent)
full_max_y = max(float(n.world_y) for n in extent)
if min_x is None or max_x is None or min_y is None or max_y is None:
min_x, max_x, min_y, max_y = full_min_x, full_max_x, full_min_y, full_max_y
q = db.query(TopoFabricNode).filter(
TopoFabricNode.world_x.isnot(None),
TopoFabricNode.world_y.isnot(None),
TopoFabricNode.world_x >= min_x,
TopoFabricNode.world_x <= max_x,
TopoFabricNode.world_y >= min_y,
TopoFabricNode.world_y <= max_y,
)
if region_folder_ids is not None:
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
if sbn_id.strip():
sid = sbn_id.strip()
nodes = [
n
for n in q.all()
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
or str(n.region_folder_id or "") == sid
]
else:
nodes = q.all()
truncated = False
reason = ""
if len(nodes) > WORLD_NODE_SOFT_CAP:
nodes = nodes[:WORLD_NODE_SOFT_CAP]
truncated = True
reason = "too_many_viewport_nodes"
fids = [n.id for n in nodes]
fid_set = set(fids)
nodes_out = [
ViewNodeOut(
fabric_node_id=n.id,
managed_ne_id=str(n.managed_ne_id or ""),
ume_ne_id=str(n.ume_ne_id or ""),
label=(n.name or n.ip or n.id)[:256],
x=float(n.world_x or 0),
y=float(n.world_y or 0),
locked=False,
name=n.name or "",
ip=n.ip or "",
vendor=n.vendor or "",
device_type=n.device_type or "",
kind="ne",
)
for n in nodes
]
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
edges_out: list[ViewEdgeOut] = []
if fids:
eq = db.query(TopoFabricEdge).filter(
TopoFabricEdge.layer == "physical",
TopoFabricEdge.a_node_id.in_(fids),
TopoFabricEdge.b_node_id.in_(fids),
)
st = str(status or "").strip().lower()
if st:
st_norm = _normalize_edge_status(st)
if st_norm == _EDGE_STATUS_MISSING:
eq = eq.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT)))
else:
eq = eq.filter(TopoFabricEdge.status == st_norm)
edges = eq.limit(VIEW_GRAPH_EDGE_HARD_CAP + 1).all()
if len(edges) > VIEW_GRAPH_EDGE_HARD_CAP:
edges = edges[:VIEW_GRAPH_EDGE_HARD_CAP]
truncated = True
reason = reason or "too_many_edges"
for e in edges:
if e.a_node_id not in fid_set or e.b_node_id not in fid_set:
continue
edges_out.append(
ViewEdgeOut(
id=e.id,
a_node_id=e.a_node_id,
b_node_id=e.b_node_id,
a_port=e.a_port or "",
b_port=e.b_port or "",
source=e.source or "lldp",
status=e.status or "active",
layer=e.layer or "physical",
discovered_at=e.discovered_at,
)
)
return TopologyViewGraphOut(
view=_view_out(view),
nodes=nodes_out,
edges=edges_out,
truncated=truncated,
truncate_reason=reason,
)