diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 2892573..d94c9a7 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -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: diff --git a/netx_api/ume_topology_apply.py b/netx_api/ume_topology_apply.py index c7b8dc0..b8f8eab 100644 --- a/netx_api/ume_topology_apply.py +++ b/netx_api/ume_topology_apply.py @@ -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() diff --git a/netx_api/ume_topology_world.py b/netx_api/ume_topology_world.py index 26a51d4..bab29b1 100644 --- a/netx_api/ume_topology_world.py +++ b/netx_api/ume_topology_world.py @@ -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: diff --git a/netx_api/ume_topology_world_graph.py b/netx_api/ume_topology_world_graph.py index 2954e7a..2f2e045 100644 --- a/netx_api/ume_topology_world_graph.py +++ b/netx_api/ume_topology_world_graph.py @@ -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 = "" diff --git a/tests/test_topology.py b/tests/test_topology.py index d57d151..bb6eb79 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -319,9 +319,17 @@ class FabricTopologyTests(unittest.TestCase): self.assertEqual(action, "skipped_self_loop") def _region(self, name: str = "Test-Region") -> str: - return svc.create_folder( + """Return the unique L2「根图」canvas id under a new top-level「根」.""" + top = svc.create_folder( self.db, TopologyFolderCreate(name=name, kind="region") - ).id + ) + child = ( + self.db.query(TopoFolder) + .filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region") + .order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc()) + .first() + ) + return child.id if child is not None else top.id def test_view_crud_and_positions(self) -> None: suffix = uuid4().hex[:8] @@ -394,93 +402,96 @@ class FabricTopologyTests(unittest.TestCase): region = svc.create_folder( self.db, TopologyFolderCreate(name="East", kind="region") ) - # Creating a region does not auto-create a map — user/MCP adds views. + # Top-level「根」is nav-only; unique L2「根图」holds the canvas. tree1 = svc.get_topology_tree(self.db) assert tree1.root is not None east0 = next(c for c in tree1.root.children if c.id == region.id) self.assertEqual(east0.views, []) + self.assertEqual(len(east0.children), 1) + root_map = east0.children[0] + self.assertEqual(root_map.name, "根图") + self.assertEqual(len(root_map.views), 1) + self.assertEqual(root_map.views[0].name, "根图") view = svc.create_view( self.db, TopologyViewCreate( name="East-Custom", - folder_id=region.id, + folder_id=root_map.id, kind="custom", ), ) - self.assertEqual(view.folder_id, region.id) + self.assertEqual(view.folder_id, root_map.id) self.assertEqual(view.kind, "custom") self.assertIn("membership", view.filter) tree2 = svc.get_topology_tree(self.db) assert tree2.root is not None east = next(c for c in tree2.root.children if c.id == region.id) - self.assertTrue(any(v.id == view.id for v in east.views)) - self.assertEqual(len(east.views), 1) - self.assertTrue(all(not getattr(v, "children", None) for v in east.views)) + rm = east.children[0] + self.assertTrue(any(v.id == view.id for v in rm.views)) + self.assertEqual(len(rm.views), 2) + self.assertTrue(all(not getattr(v, "children", None) for v in rm.views)) def test_site_physical_and_custom_flat(self) -> None: - region = svc.create_folder( + top = svc.create_folder( self.db, TopologyFolderCreate(name="Site-R", kind="region") ) - phys = svc.create_view( - self.db, - TopologyViewCreate( - name="Physical", - folder_id=region.id, - kind="physical", - ), - ) - tree = svc.get_topology_tree(self.db) - assert tree.root is not None - reg = next(c for c in tree.root.children if c.id == region.id) - physicals = [v for v in reg.views if v.kind == "physical"] - self.assertEqual(len(physicals), 1) - self.assertEqual(physicals[0].id, phys.id) + tree0 = svc.get_topology_tree(self.db) + assert tree0.root is not None + nav = next(c for c in tree0.root.children if c.id == top.id) + root_map = nav.children[0] + self.assertEqual(root_map.name, "根图") + auto_phys = root_map.views[0] + self.assertEqual(auto_phys.kind, "physical") custom = svc.create_view( self.db, TopologyViewCreate( name="Custom-A", - folder_id=region.id, + folder_id=root_map.id, kind="custom", ), ) tree2 = svc.get_topology_tree(self.db) assert tree2.root is not None - reg2 = next(c for c in tree2.root.children if c.id == region.id) + reg2 = next(c for c in tree2.root.children if c.id == top.id).children[0] ids = {v.id for v in reg2.views} - self.assertIn(phys.id, ids) + self.assertIn(auto_phys.id, ids) self.assertIn(custom.id, ids) # physical first self.assertEqual(reg2.views[0].kind, "physical") with self.assertRaises(Exception): - svc.delete_view(self.db, phys.id) + svc.delete_view(self.db, auto_phys.id) svc.delete_view(self.db, custom.id) # force-delete physical does not recreate another map - svc.delete_view(self.db, phys.id, force=True) + svc.delete_view(self.db, auto_phys.id, force=True) tree3 = svc.get_topology_tree(self.db) assert tree3.root is not None - reg3 = next(c for c in tree3.root.children if c.id == region.id) + reg3 = next(c for c in tree3.root.children if c.id == top.id).children[0] self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 0) def test_delete_region_cascades_views(self) -> None: region = svc.create_folder( self.db, TopologyFolderCreate(name="Del-Region", kind="region") ) + tree0 = svc.get_topology_tree(self.db) + assert tree0.root is not None + nav = next(c for c in tree0.root.children if c.id == region.id) + root_map = nav.children[0] custom = svc.create_view( self.db, TopologyViewCreate( name="Custom-Del", - folder_id=region.id, + folder_id=root_map.id, kind="custom", ), ) tree = svc.get_topology_tree(self.db) assert tree.root is not None reg = next(c for c in tree.root.children if c.id == region.id) - self.assertTrue(any(v.id == custom.id for v in reg.views)) + self.assertTrue(any(v.id == custom.id for v in reg.children[0].views)) out = svc.delete_folder(self.db, region.id) self.assertTrue(out.get("deleted")) @@ -489,13 +500,20 @@ class FabricTopologyTests(unittest.TestCase): self.assertFalse(any(c.id == region.id for c in tree2.root.children)) self.assertIsNone(self.db.get(TopoView, custom.id)) self.assertEqual( - self.db.query(TopoView).filter(TopoView.folder_id == region.id).count(), + self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(), 0, ) def test_nested_region_places_icon_on_parent_canvas(self) -> None: + # Top-level「根」is nav-only; canvas work happens under unique「根图」. + container = svc.create_folder( + self.db, TopologyFolderCreate(name="Site-Group", kind="region") + ) parent = svc.create_folder( - self.db, TopologyFolderCreate(name="Parent-Canvas", kind="region") + self.db, + TopologyFolderCreate( + name="Parent-Canvas", kind="region", parent_id=container.id + ), ) child = svc.create_folder( self.db, @@ -503,7 +521,13 @@ class FabricTopologyTests(unittest.TestCase): ) tree = svc.get_topology_tree(self.db) assert tree.root is not None - p = next(c for c in tree.root.children if c.id == parent.id) + group = next(c for c in tree.root.children if c.id == container.id) + self.assertEqual(group.views, []) + self.assertEqual(len(group.children), 1) + root_map = group.children[0] + self.assertEqual(root_map.name, "根图") + # Creating under the nav「根」remounts onto「根图」. + p = next(c for c in root_map.children if c.id == parent.id) self.assertTrue(p.views) graph = svc.get_view_graph(self.db, p.views[0].id) regions = [n for n in graph.nodes if n.kind == "region"] @@ -540,6 +564,50 @@ class FabricTopologyTests(unittest.TestCase): graph2 = svc.get_view_graph(self.db, p.views[0].id) self.assertFalse(any(n.kind == "region" for n in graph2.nodes)) + def test_top_level_stays_nav_when_adding_child(self) -> None: + """Creating under「根」remounts onto unique「根图」; top stays nav-only.""" + top = svc.create_folder( + self.db, TopologyFolderCreate(name="East-Nav", kind="region") + ) + child = svc.create_folder( + self.db, + TopologyFolderCreate(name="Site-A", kind="region", parent_id=top.id), + ) + tree = svc.get_topology_tree(self.db) + assert tree.root is not None + east = next(c for c in tree.root.children if c.id == top.id) + self.assertEqual(east.views, []) + self.assertEqual(len(east.children), 1) + root_map = east.children[0] + self.assertEqual(root_map.name, "根图") + self.assertEqual(len(root_map.views), 1) + site = next(c for c in root_map.children if c.id == child.id) + self.assertEqual(len(site.views), 1) + self.assertEqual(site.views[0].name, "Site-A") + self.assertNotEqual(site.views[0].name, "Physical topology") + + def test_root_auto_spawns_unique_root_map(self) -> None: + top = svc.create_folder( + self.db, TopologyFolderCreate(name="West", kind="region") + ) + # Second create under the same「根」must remount under existing「根图」. + a = svc.create_folder( + self.db, + TopologyFolderCreate(name="A", kind="region", parent_id=top.id), + ) + b = svc.create_folder( + self.db, + TopologyFolderCreate(name="B", kind="region", parent_id=top.id), + ) + tree = svc.get_topology_tree(self.db) + assert tree.root is not None + west = next(c for c in tree.root.children if c.id == top.id) + self.assertEqual(len(west.children), 1) + root_map = west.children[0] + self.assertEqual(root_map.name, "根图") + child_ids = {c.id for c in root_map.children} + self.assertEqual(child_ids, {a.id, b.id}) + def test_classify_role_region_and_slices(self) -> None: from netx_api import topology_classify as clf from netx_api.topology_schemas import ( diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 0cb646c..454b7eb 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1394,7 +1394,11 @@ const en = { maps: "Maps", tree: "Topology tree", treeLoading: "Loading tree…", - treeHint: "Regions hold maps you create; open a map to edit the canvas.", + graphLoading: "Loading topology…", + graphRefreshing: "Refreshing…", + canvasEmpty: "No NEs on this map yet", + canvasEmptyHint: "Use Add NE, or pick a region that already has devices.", + treeHint: "Top level is a Root (nav only); its unique Root Map is the canvas. Open the root map or a sub-region to edit devices.", treeSearch: "Search NEs across maps", treeSearchPh: "Name / IP", treeSearchEmpty: "No matching NEs", @@ -1402,47 +1406,47 @@ const en = { treeSearchNotOnMap: "This NE is not placed on any topology map yet", treeSearchViewCount: "{{count}} map(s)", treeSearchTruncated: "Showing first 30 of {{total}}", - rootName: "Regions", - selectRegion: "Select a region", + rootName: "Roots", + selectRegion: "Select a root or region", openMap: "Open map", - openRegion: "Open region", + openRegion: "Open", kindPhysical: "Physical", kindCustom: "Custom", regionNodeHint: "{{count}}P", layerNodeHint: "{{count}} NE(s)", layerNodeEmpty: "Click to create & open", - rootCanvasHint: "Pick a region from the left tree.", - regionCanvasHint: "Open a map to edit devices, or create a custom map.", - regionBrowseHint: "Region folder: maps you add. Click to open.", + rootCanvasHint: "Pick a root from the left tree.", + regionCanvasHint: "Open the root map to edit devices; create sub-regions on that canvas.", + regionBrowseHint: "Root folder: unique root map below. Click to open.", browserTitle: "Topology folder", browserRegionSub: "{{count}} map(s)", - browserRegionsSub: "{{count}} region(s)", - browserEmptyMaps: "No maps in this region yet.", + browserRegionsSub: "{{count}} item(s)", + browserEmptyMaps: "No root map under this root yet.", addNe: "Add NE", - backUp: "Back to region", - newRegion: "New region", - newRegionName: "New region", - newRegionPrompt: "Region name", + backUp: "Back", + newRegion: "New root", + newRegionName: "New root", + newRegionPrompt: "Root name", newSubRegion: "New sub-region", newSubRegionName: "New sub-region", newSubRegionPrompt: "Sub-region name", subRegionNotOnFlat: "The flat world map has no regions. Create sub-regions on World or a lower-level canvas.", worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.", - subRegionNeedParent: "Open a region canvas first, then create a sub-region.", - regionCreated: "Region created", - regionDeleted: "Region deleted", - deleteRegion: "Delete region", - deleteRegionConfirm: "Delete region \"{{name}}\"? Its maps will also be deleted.", - renameRegion: "Rename region", - renameRegionPrompt: "Region name", - regionRenamed: "Region renamed", - collapseRegion: "Collapse region", - expandRegion: "Expand region", + subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.", + regionCreated: "Created", + regionDeleted: "Deleted", + deleteRegion: "Delete", + deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.", + renameRegion: "Rename", + renameRegionPrompt: "Name", + regionRenamed: "Renamed", + collapseRegion: "Collapse", + expandRegion: "Expand", newCustom: "Custom map", newCustomName: "Custom topology", newCustomPrompt: "Custom map name", - folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.", - folderHintShort: "Region folder", + folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.", + folderHintShort: "Root folder", outsidePeers: "{{count}} outside peer(s) linked but not on this map", projectNeighbors: "Project neighbors", projectedNeighbors: "Projected {{count}} neighbor(s) onto the map", @@ -1467,7 +1471,7 @@ const en = { renamed: "Topology map renamed", deleteMap: "Delete map", deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.", - emptyMaps: "No regions yet. Create a region first.", + emptyMaps: "No roots yet. Create a root first.", palette: "NE library", paletteHint: "Select multiple to add in batch, or drag one onto the canvas", paletteDragHint: "Drag onto canvas to add", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3afd460..2498d2c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1385,7 +1385,11 @@ const zh = { maps: "拓扑图", tree: "拓扑树", treeLoading: "加载拓扑树…", - treeHint: "区域下为画布列表;点图打开设备画布(区域本身不自动建图)。", + graphLoading: "正在加载拓扑图…", + graphRefreshing: "刷新中…", + canvasEmpty: "这张图上还没有网元", + canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。", + treeHint: "顶级为「根」(导航);其下唯一「根图」为画布。点根图或子区域打开设备画布。", treeSearch: "全拓扑搜索网元", treeSearchPh: "名称 / IP", treeSearchEmpty: "没有匹配的网元", @@ -1393,47 +1397,47 @@ const zh = { treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上", treeSearchViewCount: "{{count}} 张图", treeSearchTruncated: "仅显示前 30 条(共 {{total}})", - rootName: "区域", - selectRegion: "请选择区域", + rootName: "根", + selectRegion: "请选择根或区域", openMap: "打开本图", - openRegion: "打开区域", + openRegion: "打开", kindPhysical: "物理拓扑", kindCustom: "自定义图", regionNodeHint: "{{count}}P", layerNodeHint: "{{count}} 个网元", layerNodeEmpty: "点击创建并打开", - rootCanvasHint: "从左侧选择区域进入目录。", - regionCanvasHint: "点击图打开设备画布,或新建自定义图。", - regionBrowseHint: "区域目录:自行添加的画布。点击打开。", + rootCanvasHint: "从左侧选择根进入目录。", + regionCanvasHint: "点击根图打开设备画布;可在根图上新建子区域。", + regionBrowseHint: "根目录:下方为唯一根图。点击打开。", browserTitle: "拓扑目录", browserRegionSub: "共 {{count}} 张拓扑图", - browserRegionsSub: "共 {{count}} 个区域", - browserEmptyMaps: "此区域还没有拓扑图。", + browserRegionsSub: "共 {{count}} 个", + browserEmptyMaps: "此根下还没有根图。", addNe: "添加网元", - backUp: "返回区域", - newRegion: "新建区域", - newRegionName: "新区域", - newRegionPrompt: "区域名称", + backUp: "返回", + newRegion: "新建根", + newRegionName: "新根", + newRegionPrompt: "根名称", newSubRegion: "新建子区域", newSubRegionName: "新子区域", newSubRegionPrompt: "子区域名称", subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。", worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", - subRegionNeedParent: "请先打开一个区域画布,再新建子区域。", - regionCreated: "区域已创建", - regionDeleted: "区域已删除", - deleteRegion: "删除区域", - deleteRegionConfirm: "删除区域「{{name}}」?其下画布将一并删除。", - renameRegion: "重命名区域", - renameRegionPrompt: "区域名称", - regionRenamed: "区域已重命名", - collapseRegion: "折叠区域", - expandRegion: "展开区域", + subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。", + regionCreated: "已创建", + regionDeleted: "已删除", + deleteRegion: "删除", + deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。", + renameRegion: "重命名", + renameRegionPrompt: "名称", + regionRenamed: "已重命名", + collapseRegion: "折叠", + expandRegion: "展开", newCustom: "自定义图", newCustomName: "自定义拓扑", newCustomPrompt: "自定义拓扑图名称", - folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。", - folderHintShort: "区域目录", + folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。", + folderHintShort: "根目录", outsidePeers: "图外对端 {{count}} 个(有链路未上图)", projectNeighbors: "投影邻居上图", projectedNeighbors: "已投影 {{count}} 个邻居上图", @@ -1457,7 +1461,7 @@ const zh = { renamed: "拓扑图已重命名", deleteMap: "删除图", deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。", - emptyMaps: "还没有区域,先点「新建区域」。", + emptyMaps: "还没有根,先点「新建根」。", palette: "网元库", paletteHint: "勾选后批量添加;也可拖到画布添加单台", paletteDragHint: "拖到画布添加", diff --git a/web/src/index.css b/web/src/index.css index 24d84ef..4ed9365 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,4 +1,4 @@ -* { +* { box-sizing: border-box; } @@ -7003,12 +7003,121 @@ pre { color: var(--nm-muted, #5b6b7c); } +.topo-canvas__status { + gap: 12px; + justify-items: center; + text-align: center; +} + +.topo-canvas__status p { + margin: 0; + font-size: 13px; +} + +.topo-loading-spinner { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid rgba(148, 163, 184, 0.28); + border-top-color: #60a5fa; + border-radius: 50%; + animation: topo-spin 0.7s linear infinite; +} + +.topo-loading-spinner--lg { + width: 36px; + height: 36px; + border-width: 3px; +} + +@keyframes topo-spin { + to { + transform: rotate(360deg); + } +} + +.topo-canvas__overlay { + position: absolute; + inset: 0; + z-index: 15; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 24px; + text-align: center; + pointer-events: none; +} + +.topo-canvas__overlay--loading { + background: rgba(11, 18, 32, 0.55); + backdrop-filter: blur(2px); + pointer-events: auto; +} + +.topo-canvas__overlay--empty { + background: transparent; + pointer-events: none; +} + +.topo-canvas__overlay-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #e2e8f0; +} + +.topo-canvas__overlay--empty .topo-canvas__overlay-title { + color: #94a3b8; +} + +.topo-canvas__overlay-hint { + margin: 0; + max-width: 320px; + font-size: 12px; + line-height: 1.45; + color: #94a3b8; +} + +.topo-canvas__refresh { + position: absolute; + top: 12px; + left: 50%; + z-index: 21; + display: inline-flex; + align-items: center; + gap: 8px; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(15, 23, 42, 0.92); + color: #cbd5e1; + font-size: 12px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28); + pointer-events: none; +} + +.topo-browser__empty--loading { + gap: 12px; +} + +.topo-browser__empty--loading p { + margin: 0; +} + .topo-sidebar .panel__hint { margin: 0; font-size: 12px; color: var(--nm-muted); } +.topo-tree-status { + display: inline-flex; + align-items: center; + gap: 8px; +} + .topo-sidebar .input { width: 100%; height: 32px; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 8060fda..1e24e12 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -40,7 +40,6 @@ import { createFabricManualEdge, createTopologyFolder, createTopologyPlaceholder, - createTopologyView, deleteFabricEdges, purgePlaceholderFabricNodes, deleteTopologyFolder, @@ -81,7 +80,6 @@ import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem, - TopologyViewKind, TopologyViewRole, } from "../types"; import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; @@ -182,14 +180,18 @@ function isWorldFlatViewName(name: string | undefined | null): boolean { } /** - * Region row IS the canvas (SBN / manual region / nested sub-region / World drill). - * UME World container is nav-only (hex browse for L2 + world map). + * Region row IS the canvas for nested / UME SBN / World drill. + * Top-level under Network root (and UME World container) are hex-nav only — same as UME World. */ function isRegionCanvasFolder( folder: TopologyTreeFolderItem | null | undefined, + rootId?: string, ): boolean { if (!folder) return false; if (isUmeWorldContainer(folder)) return false; + const rid = String(rootId || "").trim(); + const parent = String(folder.parent_id || "").trim(); + if (rid && parent === rid) return false; return true; } @@ -1183,6 +1185,8 @@ export function TopologyPage() { const treeQuery = useQuery({ queryKey: queryKeys.topologyTree, queryFn: fetchTopologyTree, + staleTime: liveSync ? 0 : 30_000, + refetchOnWindowFocus: liveSync, refetchInterval: liveSync ? 5000 : false, refetchIntervalInBackground: false, }); @@ -1202,6 +1206,8 @@ export function TopologyPage() { queryKey: queryKeys.topologyGraph(mapId), queryFn: () => fetchTopologyGraph(mapId), enabled: Boolean(mapId), + staleTime: liveSync ? 0 : 30_000, + refetchOnWindowFocus: liveSync, refetchInterval: liveSync && mapId ? 3000 : false, refetchIntervalInBackground: false, }); @@ -1284,6 +1290,8 @@ export function TopologyPage() { } }, [mapId, selectedFolderId, treeRoot, regions]); + const rootFolderId = String(treeRoot?.id || "").trim(); + const canvasMode = Boolean(mapId); const activeRegion = useMemo(() => { @@ -1299,22 +1307,26 @@ export function TopologyPage() { const browseEntries = useMemo( (): TopologyTreeViewItem[] => { // Region === canvas: no map subdirectory listing. - // UME World container: hex shows L2 + flat modules (handled separately). - if (!activeRegion || isRegionCanvasFolder(activeRegion) || isUmeWorldContainer(activeRegion)) { + // UME World / top-level containers: hex shows modules (handled separately). + if ( + !activeRegion || + isRegionCanvasFolder(activeRegion, rootFolderId) || + isUmeWorldContainer(activeRegion) + ) { return []; } return activeRegion.views || []; }, - [activeRegion], + [activeRegion, rootFolderId], ); const hexBrowseRegion = useMemo(() => { - // Root hex, or UME World container hex (L2 + world map modules). + // Root hex, or nav container hex (UME World / top-level manual). if (!activeRegion) return null; if (isUmeWorldContainer(activeRegion)) return activeRegion; - if (isRegionCanvasFolder(activeRegion)) return null; + if (isRegionCanvasFolder(activeRegion, rootFolderId)) return null; return activeRegion; - }, [activeRegion]); + }, [activeRegion, rootFolderId]); const umeWorldHexModules = useMemo(() => { if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null; @@ -1425,7 +1437,7 @@ export function TopologyPage() { expandFolderPath(folderId); const folder = findFolderInTree(regions, folderId); // Region === canvas: open its primary map (no map subdirectory / hex list). - if (isRegionCanvasFolder(folder)) { + if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) { setWorldFocusFolderId(""); const view = primaryViewOfFolder(folder); if (view) { @@ -1449,6 +1461,7 @@ export function TopologyPage() { regions, primaryViewOfFolder, expandFolderPath, + treeRoot?.id, ], ); @@ -1494,16 +1507,22 @@ export function TopologyPage() { return; } } - // Leaving other region canvases → root hex. - if (isRegionCanvasFolder(folder) || isUmeWorldNavFolder(folder)) { - setSelectedFolderId(""); + // Leaving other region canvases → parent nav container, else root hex. + if (isRegionCanvasFolder(folder, String(treeRoot?.id || "")) || isUmeWorldNavFolder(folder)) { + const parentId = String(folder?.parent_id || "").trim(); + const parent = parentId ? findFolderInTree(regions, parentId) : null; + if (parent && !isRegionCanvasFolder(parent, String(treeRoot?.id || ""))) { + setSelectedFolderId(parent.id); + } else { + setSelectedFolderId(""); + } } setWorldFocusFolderId(""); setMapId(""); clearDirty(); setNodes([]); setEdges([]); - }, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId]); + }, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId, treeRoot?.id]); // Deep link from classify search: /topology?view=&ne= useEffect(() => { @@ -1756,10 +1775,9 @@ export function TopologyPage() { }); } } else { + // Top-level = nav container (UME World pattern): stay on hex browse, do not open canvas. setSelectedFolderId(folder.id); - const view = hit?.views?.[0]; - if (view) setMapId(view.id); - else setMapId(""); + setMapId(""); } showOk(t("topology.regionCreated")); }, @@ -1787,21 +1805,6 @@ export function TopologyPage() { [renameRegionMut, t], ); - const createMapMut = useMutation({ - mutationFn: (input: { folder_id: string; kind: TopologyViewKind; name: string }) => - createTopologyView(input), - onSuccess: async (row) => { - await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); - await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); - clearDirty(); - if (row.folder_id) setSelectedFolderId(row.folder_id); - setMapId(row.id); - showOk(t("topology.newMap")); - window.setTimeout(() => promptRenameMap(row.id, row.name), 0); - }, - onError: (err) => showError(String(err)), - }); - const deleteMapMut = useMutation({ mutationFn: (id: string) => deleteTopologyMap(id), onSuccess: async (_out, id) => { @@ -1859,19 +1862,6 @@ export function TopologyPage() { setCtxMenu(null); }, [activeView?.name, selectedFolderId, worldViewId, mapId, regions, createRegionMut, t, showError]); - const promptNewCustom = useCallback( - (folderId: string) => { - const name = window.prompt(t("topology.newCustomPrompt"), t("topology.newCustomName")); - if (!name?.trim()) return; - createMapMut.mutate({ - folder_id: folderId, - kind: "custom", - name: name.trim(), - }); - }, - [createMapMut, t], - ); - const renderWorldNavFolder = useCallback( (folder: TopologyTreeFolderItem, depth: number): ReactNode => { const kids = folder.children || []; @@ -1881,7 +1871,7 @@ export function TopologyPage() { const umeNav = isUmeWorldNavFolder(folder); // Region === canvas: hide physical/custom view rows; only child regions. // UME World container: only「完整世界地图」as a sibling view. - const canvasRegion = isRegionCanvasFolder(folder); + const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || "")); const visibleViews = canvasRegion ? [] : containerFolder @@ -2120,6 +2110,7 @@ export function TopologyPage() { deleteFolderMut, deleteMapMut, t, + treeRoot?.id, ], ); @@ -3492,6 +3483,15 @@ export function TopologyPage() { const outsidePeers = graphQuery.data?.outside_peers || []; const graphTruncated = Boolean(graphQuery.data?.truncated); const truncateReason = String(graphQuery.data?.truncate_reason || "").trim(); + const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data); + const canvasGraphEmpty = + Boolean(mapId) && + graphQuery.isSuccess && + !graphQuery.isFetching && + (graphQuery.data?.nodes?.length ?? 0) === 0 && + nodes.length === 0; + const canvasGraphRefreshing = + Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading; const truncateBannerText = useMemo(() => { if (!graphTruncated) return ""; // World map soft-caps viewport nodes; the banner is noise there. @@ -3716,7 +3716,16 @@ export function TopologyPage() { ) : null} {!treeRoot ? ( -

{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}

+

+ {treeQuery.isLoading ? ( + <> +

) : regions.length === 0 ? (

{t("topology.emptyMaps")}

) : ( @@ -3760,7 +3769,7 @@ export function TopologyPage() { <> / {activeView && - isRegionCanvasFolder(activeRegion) && + isRegionCanvasFolder(activeRegion, rootFolderId) && primaryViewOfFolder(activeRegion)?.id === activeView.id ? ( {regionDisplayName(activeRegion)} @@ -3780,7 +3789,7 @@ export function TopologyPage() { {activeView && !( activeRegion && - isRegionCanvasFolder(activeRegion) && + isRegionCanvasFolder(activeRegion, rootFolderId) && primaryViewOfFolder(activeRegion)?.id === activeView.id ) ? ( <> @@ -4069,7 +4078,12 @@ export function TopologyPage() { {!hexBrowseRegion ? ( - regions.length === 0 ? ( + treeQuery.isLoading ? ( +
+
+ ) : regions.length === 0 ? (
- ) : browseEntries.length === 0 ? ( -
- -
) : (
+ {(hexBrowseRegion.children || []).map((region, idx) => ( + + ))} {browseEntries.map((v, idx) => { const isPhysical = String(v.kind) === "physical"; const tone = isPhysical ? "physical" : String((idx + 1) % 5); @@ -4237,7 +4265,7 @@ export function TopologyPage() { onMouseLeave={() => setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k)) } - onClick={() => goCanvas(v.id, activeRegion.id)} + onClick={() => goCanvas(v.id, hexBrowseRegion.id)} title={t("topology.openMap")} >
)} @@ -4905,8 +4939,29 @@ export function TopologyPage() { ) : ( -
{t("topology.treeLoading")}
+
+
)} + {treeRoot && canvasGraphLoading ? ( +
+
+ ) : null} + {treeRoot && canvasGraphEmpty ? ( +
+

{t("topology.canvasEmpty")}

+

{t("topology.canvasEmptyHint")}

+
+ ) : null} + {treeRoot && canvasGraphRefreshing ? ( +
+
+ ) : null} )}