From 4b7d13d62b14225c38b7435b4fdbecb1799c9e3f Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 7 Aug 2026 18:16:21 +0800 Subject: [PATCH] Unify directory NE badges as distinct fabric ids across membership and home. Co-authored-by: Cursor --- netx_api/topology_schemas.py | 2 +- netx_api/topology_views_tree.py | 219 +++++++++++++------------------- tests/test_topology.py | 99 +++++++++++++++ web/src/pages/TopologyPage.tsx | 28 +--- web/src/types.ts | 2 +- 5 files changed, 188 insertions(+), 162 deletions(-) diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 77498c7..99c35a9 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -228,7 +228,7 @@ class TopologyTreeFolderOut(BaseModel): sort_order: int = 0 is_system: bool = False external_ref: str = "" - # Real NE inventory under this folder (subtree). Region folders are not NEs. + # Real NE inventory under this folder (distinct fabric ids in subtree). ne_count: int = 0 views: list[TopologyTreeViewOut] = Field(default_factory=list) children: list["TopologyTreeFolderOut"] = Field(default_factory=list) diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index e00ef8b..34ab836 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -18,7 +18,6 @@ from .models import ( TopoViewEdgeStyle, TopoViewNode, UmeInventoryNE, - UmeTopoNode, ) from .topology_common import ( PHYSICAL_VIEW_NAME, @@ -659,82 +658,51 @@ 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() - # 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() + + # --- NE badge: source-agnostic distinct fabric ids (membership ∪ region_folder_id) --- + # Cheap aggregates only — never hydrate attrs JSON on tree load. + membership_by_view: dict[str, set[str]] = {} + for vid, fid in ( + db.query(TopoViewNode.view_id, TopoViewNode.fabric_node_id) + .filter(~TopoViewNode.fabric_node_id.like("region:%")) + .all() + ): + membership_by_view.setdefault(str(vid), set()).add(str(fid)) + + fabric_by_region: dict[str, set[str]] = {} + for fid, nid in ( + db.query(TopoFabricNode.region_folder_id, TopoFabricNode.id) + .filter( + TopoFabricNode.region_folder_id.isnot(None), + TopoFabricNode.region_folder_id != "", + or_( + and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""), + and_( + TopoFabricNode.managed_ne_id.isnot(None), + TopoFabricNode.managed_ne_id != "", + ), + ), ) - } - 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() - ) - } - # Skip heavy UME rollups on fresh installs (no UME folders / level views yet). - has_ume_dirs = any( - str(getattr(f, "external_ref", None) or "").strip() - for f in folders - if str(f.kind or "") == "region" - ) or any( - bool( - view_filter_dict(v.filter).get("ume_level") - or view_filter_dict(v.filter).get("world") - or view_filter_dict(v.filter).get("world_flat") - ) - for v in views - ) - # Fabric NE inventory by owning region folder (regions themselves are never fabric NEs). - 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.region_folder_id.isnot(None), - TopoFabricNode.region_folder_id != "", - or_( - and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""), - and_( - TopoFabricNode.managed_ne_id.isnot(None), - TopoFabricNode.managed_ne_id != "", - ), + .all() + ): + key = str(fid or "").strip() + if key: + fabric_by_region.setdefault(key, set()).add(str(nid)) + + fabric_inventory_total = int( + db.query(func.count(TopoFabricNode.id)) + .filter( + or_( + and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""), + and_( + TopoFabricNode.managed_ne_id.isnot(None), + TopoFabricNode.managed_ne_id != "", ), ) - .group_by(TopoFabricNode.region_folder_id) - .all() - ) - if str(fid or "").strip() - } - ume_ne_by_sbn: dict[str, int] = {} - ume_ne_total = 0 - if has_ume_dirs: - ume_ne_by_sbn = { - 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 != "") - .scalar() - or 0 ) + .scalar() + or 0 + ) by_parent: dict[str, list[TopoFolder]] = {} folder_by_id: dict[str, TopoFolder] = {} @@ -747,77 +715,60 @@ def get_topology_tree(db: Session) -> TopologyTreeOut: pid = str(f.parent_id or "") by_parent.setdefault(pid, []).append(f) - def _is_whole_network_folder(folder: TopoFolder) -> bool: - """UME World / World / 世界地图 — same UME inventory, different presentation. - - Manual「根」/「根图」are separate canvases and must use their own subtree counts - (empty root map → 0N), never the UME thousands. - """ - from .ume_topology_world import is_ume_world_container, is_world_drill_folder - - return is_ume_world_container(folder) or is_world_drill_folder(folder) - views_by_folder: dict[str, list[TopoView]] = {} for v in views: views_by_folder.setdefault(str(v.folder_id or ""), []).append(v) - def _view_membership_ne(v: TopoView) -> int: - """Canvas members that are real NEs (exclude region:* directory icons).""" - raw = int(nc_map.get(v.id, 0) or 0) - return max(0, raw - int(region_icon_map.get(v.id, 0) or 0)) + def _is_virtual_full_network_folder(folder: TopoFolder) -> bool: + """UME World / World — alternate presentation of full fabric inventory.""" + from .ume_topology_world import is_ume_world_container, is_world_drill_folder - def _own_canvas_ne(folder: TopoFolder) -> int: - """Manual canvas: prefer view membership; also honor fabric region ownership.""" - mem = sum( - _view_membership_ne(v) for v in views_by_folder.get(str(folder.id), []) - ) - fab = int(ne_by_folder.get(str(folder.id), 0)) - return max(mem, fab) + return is_ume_world_container(folder) or is_world_drill_folder(folder) - def _subtree_inventory(folder_id: str) -> int: - """UME-synced directory: NEs owned by this folder + all descendant folders.""" - total = int(ne_by_folder.get(folder_id, 0)) - for child in by_parent.get(folder_id, []): - total += _subtree_inventory(str(child.id)) - return total - - def _is_ume_synced_folder(folder: TopoFolder) -> bool: - return bool(str(getattr(folder, "external_ref", None) or "").strip()) - - def _folder_ne_count(folder: TopoFolder) -> int: - if _is_whole_network_folder(folder): - return int(ume_ne_total or 0) - # UME SBN / synced dirs: fabric ownership tree (not empty manual canvases). - if _is_ume_synced_folder(folder): - return _subtree_inventory(str(folder.id)) - # Manual 根/根图/子区域: count NEs placed on canvases (+ nested regions). - own = _own_canvas_ne(folder) - nested = sum( - _folder_ne_count(child) for child in by_parent.get(str(folder.id), []) - ) - return own + nested - - def _view_node_count(v: TopoView) -> int: - folder = folder_by_id.get(str(v.folder_id or "")) - if folder is not None and _is_whole_network_folder(folder): - return int(ume_ne_total or 0) + def _is_virtual_full_network_view(v: TopoView) -> bool: filt = view_filter_dict(v.filter) if filt.get("world_flat") or ( bool(filt.get("world")) and not filt.get("ume_level") ): - return int(ume_ne_total or 0) - if filt.get("ume_level"): - sid = str(filt.get("sbn_id") or "").strip() - if sid: - return int(ume_ne_by_sbn.get(sid, 0)) - if str(filt.get("parent") or "") == "md": - return int(ume_ne_total or 0) - mem = _view_membership_ne(v) - if mem: - return mem - if folder is not None: - return int(ne_by_folder.get(str(folder.id), 0)) - return 0 + return True + if filt.get("ume_level") and str(filt.get("parent") or "") == "md": + return True + folder = folder_by_id.get(str(v.folder_id or "")) + return bool(folder and _is_virtual_full_network_folder(folder)) + + def _own_ne_ids(folder: TopoFolder) -> set[str]: + """NEs on this folder's canvases ∪ fabric default-home on this folder.""" + ids: set[str] = set(fabric_by_region.get(str(folder.id), set())) + for v in views_by_folder.get(str(folder.id), []): + ids |= membership_by_view.get(str(v.id), set()) + return ids + + # Bottom-up distinct union: folder_id -> ne id set (cached while building). + subtree_ne_ids: dict[str, set[str]] = {} + + def _subtree_ne_ids(folder: TopoFolder) -> set[str]: + fid = str(folder.id) + if fid in subtree_ne_ids: + return subtree_ne_ids[fid] + if _is_virtual_full_network_folder(folder): + # Don't expand full inventory into a giant set; count handled separately. + subtree_ne_ids[fid] = set() + return subtree_ne_ids[fid] + ids = set(_own_ne_ids(folder)) + for child in by_parent.get(fid, []): + ids |= _subtree_ne_ids(child) + subtree_ne_ids[fid] = ids + return ids + + def _folder_ne_count(folder: TopoFolder) -> int: + if _is_virtual_full_network_folder(folder): + return fabric_inventory_total + return len(_subtree_ne_ids(folder)) + + def _view_node_count(v: TopoView) -> int: + if _is_virtual_full_network_view(v): + return fabric_inventory_total + return len(membership_by_view.get(str(v.id), set())) def _flat_views(folder: TopoFolder, folder_views: list[TopoView]) -> list[TopologyTreeViewOut]: from .ume_topology_world import ( diff --git a/tests/test_topology.py b/tests/test_topology.py index 333899b..8c446a0 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -491,6 +491,105 @@ class FabricTopologyTests(unittest.TestCase): self.assertEqual(len(rm.views), 2) self.assertTrue(all(not getattr(v, "children", None) for v in rm.views)) + def test_tree_ne_count_distinct_membership_and_home(self) -> None: + """Directory N = distinct fabric ids (membership ∪ region_folder_id); parent unions.""" + site = svc.create_folder( + self.db, TopologyFolderCreate(name="NE-Count-Site", kind="region") + ) + tree0 = svc.get_topology_tree(self.db) + assert tree0.root is not None + site_f = next(c for c in tree0.root.children if c.id == site.id) + root_map = site_f.children[0] + self.assertEqual(site_f.ne_count, 0) + self.assertEqual(root_map.ne_count, 0) + self.assertEqual(root_map.views[0].node_count, 0) + + a = svc.create_folder( + self.db, + TopologyFolderCreate(name="Zone-A", kind="region", parent_id=site.id), + ) + b = svc.create_folder( + self.db, + TopologyFolderCreate(name="Zone-B", kind="region", parent_id=site.id), + ) + tree1 = svc.get_topology_tree(self.db) + assert tree1.root is not None + site1 = next(c for c in tree1.root.children if c.id == site.id) + rm1 = site1.children[0] + za = next(c for c in rm1.children if c.id == a.id) + zb = next(c for c in rm1.children if c.id == b.id) + # Empty nested regions (+ region icons on parent) still 0 NE. + self.assertEqual(za.ne_count, 0) + self.assertEqual(zb.ne_count, 0) + self.assertEqual(rm1.ne_count, 0) + + suffix = uuid4().hex[:8] + shared = TopoFabricNode( + id=f"ne-shared-{suffix}", + name=f"Shared-{suffix}", + ip="10.66.0.1", + managed_ne_id=f"mne-shared-{suffix}", + ) + only_a = TopoFabricNode( + id=f"ne-a-{suffix}", + name=f"OnlyA-{suffix}", + ip="10.66.0.2", + managed_ne_id=f"mne-a-{suffix}", + ) + home_only = TopoFabricNode( + id=f"ne-home-{suffix}", + name=f"Home-{suffix}", + ip="10.66.0.3", + managed_ne_id=f"mne-home-{suffix}", + region_folder_id=a.id, + ) + self.db.add_all([shared, only_a, home_only]) + self.db.add( + TopoViewNode( + id=uuid4().hex, + view_id=za.views[0].id, + fabric_node_id=shared.id, + x=0, + y=0, + ) + ) + self.db.add( + TopoViewNode( + id=uuid4().hex, + view_id=zb.views[0].id, + fabric_node_id=shared.id, + x=1, + y=1, + ) + ) + self.db.add( + TopoViewNode( + id=uuid4().hex, + view_id=za.views[0].id, + fabric_node_id=only_a.id, + x=2, + y=2, + ) + ) + self.db.commit() + + tree2 = svc.get_topology_tree(self.db) + assert tree2.root is not None + site2 = next(c for c in tree2.root.children if c.id == site.id) + rm2 = site2.children[0] + za2 = next(c for c in rm2.children if c.id == a.id) + zb2 = next(c for c in rm2.children if c.id == b.id) + + # Zone-A: shared + only_a + home_only (region_folder_id, no membership) + self.assertEqual(za2.ne_count, 3) + self.assertEqual(za2.views[0].node_count, 2) + # Zone-B: shared only + self.assertEqual(zb2.ne_count, 1) + self.assertEqual(zb2.views[0].node_count, 1) + # Parent 根图: distinct union → shared, only_a, home_only = 3 (not 3+1) + self.assertEqual(rm2.ne_count, 3) + self.assertEqual(site2.ne_count, 3) + def test_manual_root_map_name_follows_locale(self) -> None: """English UI creates Root map; zh (default) keeps 根图.""" en_root = svc.create_folder( diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 63eb629..91be60e 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -297,34 +297,10 @@ function regionDisplayName( return raw; } -/** Prefer the folder's primary canvas view (physical / world flat / World drill). */ -function primaryTreeView( - folder: TopologyTreeFolderItem | null | undefined, -): TopologyTreeViewItem | null { - if (!folder) return null; - const views = folder.views || []; - if (!views.length) return null; - if (isWorldDrillFolder(folder)) { - return views.find((v) => v.name === "World") || views[0] || null; - } - if (isUmeWorldContainer(folder)) { - return views.find((v) => isWorldFlatViewName(v.name)) || views[0] || null; - } - return views[0] || null; -} - -/** - * Directory NE badge from API. - * Manual canvases count view membership (placed NEs); UME World surfaces use inventory. - */ +/** Directory NE badge: API already returns distinct-subtree ne_count. */ function folderNeCount(folder: TopologyTreeFolderItem | null | undefined): number { if (!folder) return 0; - if (folder.ne_count != null && Number.isFinite(Number(folder.ne_count))) { - return Math.max(0, Math.floor(Number(folder.ne_count) || 0)); - } - // Legacy fallback before API ne_count: primary view only (no recursive sum). - const primary = primaryTreeView(folder); - return primary ? Math.max(0, Math.floor(Number(primary.node_count) || 0)) : 0; + return Math.max(0, Math.floor(Number(folder.ne_count) || 0)); } function formatNeCount(count: number): string { diff --git a/web/src/types.ts b/web/src/types.ts index a058a7c..e898185 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -427,7 +427,7 @@ export type TopologyTreeFolderItem = { sort_order: number; is_system: boolean; external_ref?: string; - /** Real NE inventory under this folder (subtree). Region folders are not NEs. */ + /** Distinct fabric NE count in this folder subtree (API). Region icons excluded. */ ne_count?: number; views: TopologyTreeViewItem[]; children: TopologyTreeFolderItem[];