mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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:
parent
edfa3f8756
commit
48b10656d2
9 changed files with 867 additions and 366 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue