Unify manual topology roots with UME World: auto root map, and fix empty UME canvas.

Top-level folders are nav-only with a unique system root map; UI drops custom/extra L2 creates. Batched dock-to-Fabric apply avoids PG lock OOM so World NEs can render, with subtree region counts restored.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 02:22:58 +08:00
parent edfa3f8756
commit 48b10656d2
9 changed files with 867 additions and 366 deletions

View file

@ -18,6 +18,7 @@ from .models import (
TopoViewEdgeStyle,
TopoViewNode,
UmeInventoryNE,
UmeTopoNode,
)
from .topology_common import (
PHYSICAL_VIEW_NAME,
@ -28,6 +29,9 @@ from .topology_common import (
_normalize_edge_status,
_utcnow,
)
# Manual top-level「根」auto-spawns this unique L2 canvas (mirrors UME World / World).
MANUAL_ROOT_MAP_NAME = "根图"
from .topology_fabric import (
_edge_out,
_fabric_match_score,
@ -261,6 +265,50 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
parent = drill
parent_kind = "region"
# Manual 根 (top-level nav): unique L2「根图」only — further creates remount under it.
if parent_kind == "region" and str(parent.parent_id or "") == str(root.id):
existing_l2 = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == parent.id, TopoFolder.kind == "region")
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
.all()
)
if existing_l2:
parent = existing_l2[0]
parent_kind = "region"
else:
# Legacy 根 without 根图 — heal then remount.
heal_now = _utcnow()
root_map = TopoFolder(
id=uuid4().hex,
parent_id=parent.id,
kind="region",
name=MANUAL_ROOT_MAP_NAME,
sort_order=0,
is_system=True,
created_at=heal_now,
updated_at=heal_now,
)
db.add(root_map)
db.flush()
db.add(
TopoView(
id=uuid4().hex,
folder_id=root_map.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=MANUAL_ROOT_MAP_NAME,
remark="",
sort_order=0,
filter={},
viewport={},
created_at=heal_now,
updated_at=heal_now,
)
)
db.flush()
parent = root_map
parent_kind = "region"
now = _utcnow()
row = TopoFolder(
id=uuid4().hex,
@ -274,26 +322,68 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
)
db.add(row)
db.flush()
# Nested / any new region gets a blank physical canvas so the tree can open it.
phys = TopoView(
id=uuid4().hex,
folder_id=row.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=name[:200] or "Topology",
remark="",
sort_order=0,
filter={},
viewport={},
created_at=now,
updated_at=now,
)
db.add(phys)
# Nested region: place a building icon on the parent's canvas (region === canvas).
if parent_kind == "region":
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
if parent_kind == "root":
# 「根」nav container + auto unique「根图」canvas (UME World / World pattern).
root_map = TopoFolder(
id=uuid4().hex,
parent_id=row.id,
kind="region",
name=MANUAL_ROOT_MAP_NAME,
sort_order=0,
is_system=True,
created_at=now,
updated_at=now,
)
db.add(root_map)
db.flush()
db.add(
TopoView(
id=uuid4().hex,
folder_id=root_map.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=MANUAL_ROOT_MAP_NAME,
remark="",
sort_order=0,
filter={},
viewport={},
created_at=now,
updated_at=now,
)
)
elif parent_kind == "region":
# Nested under 根图 / deeper canvas: region === canvas + icon on parent.
phys = TopoView(
id=uuid4().hex,
folder_id=row.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=name[:200] or "Topology",
remark="",
sort_order=0,
filter={},
viewport={},
created_at=now,
updated_at=now,
)
db.add(phys)
db.flush()
parent_has_phys = (
db.query(TopoView.id)
.filter(TopoView.folder_id == parent.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.first()
is not None
)
if parent_has_phys:
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
else:
ensure_region_physical_view(db, parent.id, commit=False)
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
db.flush()
reconcile_world_flat_view(db)
db.commit()
@ -338,28 +428,41 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To
def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
"""Delete a region and cascade-delete its maps.
"""Delete a region and cascade-delete nested regions + maps.
``force`` is accepted for API compatibility; cascade always runs.
System L2「根图」may be removed only as part of cascading from its parent「根」.
"""
row = _get_folder_or_404(db, folder_id)
if str(row.kind or "") == "root" or bool(row.is_system):
if str(row.kind or "") == "root":
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
if bool(row.is_system):
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
_ = force
from .topology_region_canvas import remove_region_canvas_placements
remove_region_canvas_placements(db, row.id)
views = db.query(TopoView).filter(TopoView.folder_id == row.id).all()
for v in views:
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
synchronize_session=False
def _purge_folder(folder: TopoFolder) -> None:
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder.id)
.all()
)
db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).delete(
synchronize_session=False
)
db.delete(v)
db.flush()
db.delete(row)
for kid in kids:
_purge_folder(kid)
remove_region_canvas_placements(db, folder.id)
views = db.query(TopoView).filter(TopoView.folder_id == folder.id).all()
for v in views:
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
synchronize_session=False
)
db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).delete(
synchronize_session=False
)
db.delete(v)
db.flush()
db.delete(folder)
_purge_folder(row)
from .ume_topology_world import reconcile_world_flat_view
reconcile_world_flat_view(db)
@ -371,38 +474,61 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
bootstrap_topology_tree(db)
folders = db.query(TopoFolder).order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc()).all()
views = db.query(TopoView).order_by(TopoView.sort_order.asc(), TopoView.name.asc()).all()
nc_map: dict[str, int] = {}
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.group_by(TopoViewNode.view_id)
.all()
):
nc_map[str(vid)] = int(cnt or 0)
region_icon_map: dict[str, int] = {}
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.filter(TopoViewNode.fabric_node_id.like("region:%"))
.group_by(TopoViewNode.view_id)
.all()
):
region_icon_map[str(vid)] = int(cnt or 0)
# UME canvases are virtual (no TopoViewNode membership) — count fabric MEs instead.
ume_ne_by_sbn: dict[str, int] = {}
ume_ne_by_folder: dict[str, int] = {}
ume_ne_total = 0
for fn in (
db.query(TopoFabricNode)
# Cheap directory counts — never hydrate 15k fabric rows (attrs JSON) on tree load.
# Full inventory scans belong only to the world flat map graph.
nc_map: dict[str, int] = {
str(vid): int(cnt or 0)
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.group_by(TopoViewNode.view_id)
.all()
)
}
region_icon_map: dict[str, int] = {
str(vid): int(cnt or 0)
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.filter(TopoViewNode.fabric_node_id.like("region:%"))
.group_by(TopoViewNode.view_id)
.all()
)
}
ume_ne_by_folder: dict[str, int] = {
str(fid): int(cnt or 0)
for fid, cnt in (
db.query(TopoFabricNode.region_folder_id, func.count(TopoFabricNode.id))
.filter(
TopoFabricNode.ume_ne_id.isnot(None),
TopoFabricNode.ume_ne_id != "",
TopoFabricNode.region_folder_id.isnot(None),
TopoFabricNode.region_folder_id != "",
)
.group_by(TopoFabricNode.region_folder_id)
.all()
)
if str(fid or "").strip()
}
# Direct ME counts by UME parent SBN (level-by-level; not full subtree rollup).
ume_ne_by_sbn: dict[str, int] = {
str(parent): int(cnt or 0)
for parent, cnt in (
db.query(UmeTopoNode.parent_node, func.count(UmeTopoNode.node_id))
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node.isnot(None),
UmeTopoNode.parent_node != "",
)
.group_by(UmeTopoNode.parent_node)
.all()
)
if str(parent or "").strip()
}
ume_ne_total = int(
db.query(func.count(TopoFabricNode.id))
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
.all()
):
ume_ne_total += 1
fid = str(fn.region_folder_id or "").strip()
if fid:
ume_ne_by_folder[fid] = ume_ne_by_folder.get(fid, 0) + 1
sid = str((fn.attrs or {}).get("ume_sbn_id") or "").strip()
if sid:
ume_ne_by_sbn[sid] = ume_ne_by_sbn.get(sid, 0) + 1
.scalar()
or 0
)
def _view_node_count(v: TopoView) -> int:
raw = int(nc_map.get(v.id, 0) or 0)
@ -418,7 +544,7 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
sid = str(filt.get("sbn_id") or "").strip()
if sid:
return int(ume_ne_by_sbn.get(sid, 0))
# World drill root: all UME MEs.
# World drill root: directory hint only (open canvas for real graph).
if str(filt.get("parent") or "") == "md":
return ume_ne_total
if v.folder_id:

View file

@ -68,7 +68,10 @@ def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
.filter(UmeTopoNode.node_type == "TOPO_NODE_ME")
.all()
)
for tn in me_nodes:
# Advisory xact locks + nested savepoints accumulate; commit in batches or PG
# hits OutOfMemory / max_locks_per_transaction on ~15k MEs.
_NODE_BATCH = 250
for i, tn in enumerate(me_nodes, start=1):
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
@ -118,8 +121,12 @@ def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
attrs["ume_sbn_id"] = str(tn.parent_node)[:128]
fn.attrs = attrs
fabric_by_ume[uid] = fn
if i % _NODE_BATCH == 0:
db.commit()
if i % 2000 == 0:
_log.info("ume topology apply nodes progress %s/%s", i, len(me_nodes))
db.flush()
db.commit()
seen_edge_ids: set[str] = set()
links = db.query(UmeTopoLink).all()

View file

@ -123,16 +123,21 @@ def get_world_flat_view(db: Session) -> TopoView | None:
return None
def _folder_subtree_ids(db: Session, folder_id: str) -> set[str]:
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)
def _folder_subtree_ids(
db: Session,
folder_id: str,
*,
children_map: dict[str | None, list[str]] | None = None,
) -> set[str]:
if children_map is None:
children_map = {}
for fid, parent_id in db.query(TopoFolder.id, TopoFolder.parent_id).all():
children_map.setdefault(parent_id, []).append(fid)
ids = {folder_id}
stack = [folder_id]
while stack:
cur = stack.pop()
for cid in children.get(cur, []):
for cid in children_map.get(cur, []):
if cid not in ids:
ids.add(cid)
stack.append(cid)
@ -142,25 +147,28 @@ def _folder_subtree_ids(db: Session, folder_id: str) -> set[str]:
def _child_region_has_nes(db: Session, child: TopoFolder) -> bool:
"""True when the child region subtree owns at least one fabric NE."""
ids = _folder_subtree_ids(db, child.id)
n = (
db.query(TopoFabricNode)
if ids and (
db.query(TopoFabricNode.id)
.filter(TopoFabricNode.region_folder_id.in_(list(ids)))
.limit(1)
.first()
)
if n is not None:
is not None
):
return True
# Cheap fallback: direct UME ME under this SBN (never scan all fabric attrs).
ref = str(getattr(child, "external_ref", None) or "").strip()
if ref and not ref.startswith("ume:"):
for fn in (
db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
.all()
if (
db.query(UmeTopoNode.node_id)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node == ref,
)
.limit(1)
.first()
is not None
):
if str((fn.attrs or {}).get("ume_sbn_id") or "") == ref:
return True
if str(fn.region_folder_id or "") == child.id:
return True
return True
return False
@ -180,7 +188,38 @@ def world_map_should_exist(db: Session) -> bool:
)
if not children:
return False
return any(_child_region_has_nes(db, c) for c in children)
children_map: dict[str | None, list[str]] = {}
for fid, parent_id in db.query(TopoFolder.id, TopoFolder.parent_id).all():
children_map.setdefault(parent_id, []).append(fid)
all_ids: set[str] = set()
for c in children:
all_ids |= _folder_subtree_ids(db, c.id, children_map=children_map)
if all_ids and (
db.query(TopoFabricNode.id)
.filter(TopoFabricNode.region_folder_id.in_(list(all_ids)))
.limit(1)
.first()
is not None
):
return True
refs = [
str(getattr(c, "external_ref", None) or "").strip()
for c in children
if str(getattr(c, "external_ref", None) or "").strip()
and not str(getattr(c, "external_ref", None) or "").startswith("ume:")
]
if refs and (
db.query(UmeTopoNode.node_id)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node.in_(refs),
)
.limit(1)
.first()
is not None
):
return True
return False
def reconcile_world_flat_view(db: Session) -> TopoView | None:

View file

@ -6,11 +6,13 @@ from collections import defaultdict
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeTopoNode
from .topology_common import (
VIEW_GRAPH_EDGE_HARD_CAP,
VIEW_GRAPH_NODE_HARD_CAP,
_EDGE_STATUS_MISSING,
_EDGE_STATUS_MISSING_COMPAT,
_normalize_edge_status,
@ -29,7 +31,9 @@ from .ume_topology_world import (
is_world_view,
)
WORLD_NODE_SOFT_CAP = 8000
# Keep flat-world responses at the same browser-safe hard cap as other views.
# (Previously 8000 — loading that into React Flow + API RAM starved the whole host.)
WORLD_NODE_SOFT_CAP = VIEW_GRAPH_NODE_HARD_CAP
def apply_persisted_view_positions(
@ -122,20 +126,33 @@ def get_level_view_graph(
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()
str(row[0])
for row in db.query(UmeTopoNode.node_id).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(row[0] 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
]
# Almost all MEs hang under an SBN; push the exclusion into SQL.
if sbn_ids:
child_mes = (
db.query(UmeTopoNode)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
or_(
UmeTopoNode.parent_node.is_(None),
UmeTopoNode.parent_node == "",
~UmeTopoNode.parent_node.in_(list(sbn_ids)),
),
)
.all()
)
else:
child_mes = (
db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all()
)
level_parent = "" # MD virtual
else:
if not sbn_id and view.folder_id:
@ -163,77 +180,100 @@ def get_level_view_graph(
.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
# --- Level-by-level: only THIS level's direct child SBNs + direct child MEs.
# Region badge counts roll up the whole SBN subtree (cheap: 114 SBNs + GROUP BY).
# Deep edge lifts still belong to deeper drills or the flat world map.
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}
child_sbn_id_set = set(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()
folders_by_ref: dict[str, TopoFolder] = {}
if child_sbn_id_set:
for f in (
db.query(TopoFolder)
.filter(TopoFolder.external_ref.in_(list(child_sbn_id_set)))
.all()
):
ref = str(f.external_ref or "").strip()
if ref:
folders_by_ref[ref] = f
level_view_id_by_sbn: dict[str, str] = {}
if child_sbn_id_set:
for vid, filt in db.query(TopoView.id, TopoView.filter).all():
vf = dict(filt or {})
sid = str(vf.get("sbn_id") or "").strip()
if vf.get("ume_level") and sid in child_sbn_id_set:
level_view_id_by_sbn[sid] = str(vid)
# Subtree ME counts for each direct child region (badge), without hydrating all MEs.
region_me_counts: dict[str, int] = {sid: 0 for sid in child_sbn_ids}
if child_sbn_id_set:
sbn_parent: dict[str, str] = {
str(nid): str(parent or "").strip()
for nid, parent in db.query(UmeTopoNode.node_id, UmeTopoNode.parent_node)
.filter(UmeTopoNode.node_type == "TOPO_NODE_SBN")
.all()
if str(nid or "").strip()
}
sbn_children: dict[str, list[str]] = defaultdict(list)
for sid, parent in sbn_parent.items():
if parent:
sbn_children[parent].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
region_desc: dict[str, set[str]] = {sid: _descendants(sid) for sid in child_sbn_ids}
me_by_parent: dict[str, int] = {
str(parent): int(cnt or 0)
for parent, cnt in (
db.query(UmeTopoNode.parent_node, func.count())
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node.isnot(None),
UmeTopoNode.parent_node != "",
)
.group_by(UmeTopoNode.parent_node)
.all()
)
if str(parent or "").strip()
}
for rid, desc in region_desc.items():
if parent in desc:
me_to_region[uid] = rid
break
region_me_counts[rid] = sum(me_by_parent.get(p, 0) for p in desc)
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()
}
direct_me_uids = [
uid
for uid in (str(tn.ume_ne_id or tn.node_id or "").strip() for tn in child_mes)
if uid
]
fabric_by_ume: dict[str, TopoFabricNode] = {}
if direct_me_uids:
for n in (
db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id.in_(direct_me_uids))
.all()
):
uid = str(n.ume_ne_id or "").strip()
if uid:
fabric_by_ume[uid] = n
nodes_out: list[ViewNodeOut] = []
# Region nodes
region_node_ids: dict[str, str] = {} # sbn_id -> fabric_node_id used in graph
region_node_ids: dict[str, str] = {}
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)
child_view_id = level_view_id_by_sbn.get(sid, "")
n_me = int(region_me_counts.get(sid, 0))
nid = f"region:{sid}"
region_node_ids[sid] = nid
label = (sbn.user_label or (folder.name if folder else sid) or sid)[:256]
@ -254,14 +294,13 @@ def get_level_view_graph(
device_type="region",
kind="region",
folder_id=folder.id if folder else "",
view_id=child_view.id if child_view else "",
view_id=child_view_id,
node_count=n_me,
)
)
# Direct ME nodes (local coords)
direct_me_fids: set[str] = set()
me_fid_by_uid: dict[str, str] = {}
me_uid_to_fid: dict[str, str] = {}
for tn in child_mes:
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
@ -270,7 +309,7 @@ def get_level_view_graph(
if fn is None:
continue
direct_me_fids.add(fn.id)
me_fid_by_uid[uid] = fn.id
me_uid_to_fid[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(
@ -290,42 +329,35 @@ def get_level_view_graph(
)
)
# 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
status_filter = (
list(_EDGE_STATUS_MISSING_COMPAT)
if st_norm == _EDGE_STATUS_MISSING
else [st_norm]
)
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:
if direct_me_fids:
direct_list = list(direct_me_fids)
for e in (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == "physical",
TopoFabricEdge.status.in_(status_filter),
TopoFabricEdge.a_node_id.in_(direct_list),
TopoFabricEdge.b_node_id.in_(direct_list),
)
.limit(VIEW_GRAPH_EDGE_HARD_CAP + 1)
.all()
):
if e.a_node_id == e.b_node_id:
continue
physical.append(
ViewEdgeOut(
id=e.id,
a_node_id=a,
b_node_id=b,
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",
@ -334,9 +366,58 @@ def get_level_view_graph(
discovered_at=e.discovered_at,
)
)
else:
key = (a, b) if a < b else (b, a)
logical_count[key] += 1
# Logical lifts only among MEs that hang *directly* under this level's child regions
# (one hop). Deeper nesting is loaded when the user drills into that region.
logical_count: dict[tuple[str, str], int] = defaultdict(int)
if region_node_ids and child_sbn_id_set:
me_uid_to_region: dict[str, str] = {}
for ume_ne_id, node_id, parent_node in (
db.query(UmeTopoNode.ume_ne_id, UmeTopoNode.node_id, UmeTopoNode.parent_node)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node.in_(list(child_sbn_id_set)),
)
.all()
):
uid = str(ume_ne_id or node_id or "").strip()
parent = str(parent_node or "").strip()
if uid and parent in region_node_ids:
me_uid_to_region[uid] = parent
if me_uid_to_region:
fid_to_canvas: dict[str, str] = {}
for fid, uid in (
db.query(TopoFabricNode.id, TopoFabricNode.ume_ne_id)
.filter(TopoFabricNode.ume_ne_id.in_(list(me_uid_to_region.keys())))
.all()
):
rid = me_uid_to_region.get(str(uid or "").strip(), "")
if rid:
fid_to_canvas[str(fid)] = region_node_ids[rid]
for fid in direct_me_fids:
fid_to_canvas[fid] = fid
fids = list(fid_to_canvas.keys())
if fids:
for a_id, b_id in (
db.query(TopoFabricEdge.a_node_id, TopoFabricEdge.b_node_id)
.filter(
TopoFabricEdge.layer == "physical",
TopoFabricEdge.status.in_(status_filter),
TopoFabricEdge.a_node_id.in_(fids),
TopoFabricEdge.b_node_id.in_(fids),
)
.all()
):
a = fid_to_canvas.get(str(a_id))
b = fid_to_canvas.get(str(b_id))
if not a or not b or a == b:
continue
if a in direct_me_fids and b in direct_me_fids:
continue
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
@ -354,7 +435,6 @@ def get_level_view_graph(
)
)
# 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"}
@ -413,18 +493,24 @@ def get_flat_view_graph(
min_y = float(bbox["min_y"]) - pad_y
max_y = float(bbox["max_y"]) + pad_y
extent = (
db.query(TopoFabricNode)
extent_row = (
db.query(
func.min(TopoFabricNode.world_x),
func.max(TopoFabricNode.world_x),
func.min(TopoFabricNode.world_y),
func.max(TopoFabricNode.world_y),
func.count(TopoFabricNode.id),
)
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
.all()
.one()
)
if not extent:
if not int(extent_row[4] or 0):
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)
full_min_x = float(extent_row[0])
full_max_x = float(extent_row[1])
full_min_y = float(extent_row[2])
full_max_y = float(extent_row[3])
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
@ -441,14 +527,17 @@ def get_flat_view_graph(
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
if sbn_id.strip():
sid = sbn_id.strip()
# Attr JSON filter is rare; keep Python filter but only after a bounded fetch.
candidates = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP * 5).all()
nodes = [
n
for n in q.all()
for n in candidates
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
or str(n.region_folder_id or "") == sid
]
else:
nodes = q.all()
# Cap in SQL so we never hydrate 15k ORM rows for a single browser canvas.
nodes = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP + 1).all()
truncated = False
reason = ""