diff --git a/netx_api/topology_membership.py b/netx_api/topology_membership.py index ef9c4dc..f006d9f 100644 --- a/netx_api/topology_membership.py +++ b/netx_api/topology_membership.py @@ -19,7 +19,8 @@ ROLE_DEFAULT_MAX_NODES = { VIEW_ROLE_ACCESS: 300, } KIND_DEFAULT_MAX_NODES = { - VIEW_KIND_PHYSICAL: 500, + # Physical 根图 / region canvases: allow full metro on one map (hard cap 2000). + VIEW_KIND_PHYSICAL: 2000, VIEW_KIND_CUSTOM: 300, } diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 99c35a9..efe57d1 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -253,12 +253,16 @@ class ViewProjectNeighborsRequest(BaseModel): When provided, expand only from those seeds (resolved to fabric nodes that are already placed on the view). + region_folder_id: when set, only add peers whose fabric.region_folder_id + matches (exact). Skipped peers are counted on the graph response. + dry_run=True: return the would-be graph without writing placements (canvas can apply locally and persist on Save). """ seed_fabric_node_ids: list[str] = Field(default_factory=list) managed_ne_ids: list[str] = Field(default_factory=list) + region_folder_id: str | None = None dry_run: bool = False @@ -338,6 +342,9 @@ class TopologyViewGraphOut(BaseModel): truncated: bool = False truncate_reason: str = "" outside_peers: list[dict[str, str]] = Field(default_factory=list) + # Set by project-neighbors when region_folder_id filters peers out. + out_of_region_skipped: int = 0 + out_of_region_sample: list[dict[str, str]] = Field(default_factory=list) world_transform: WorldTransformOut | None = None # Overview starfield: all (or capped) display coords; RF nodes stay empty far out. scatter: list[WorldScatterPointOut] = Field(default_factory=list) diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index 1f165d4..197e510 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -297,7 +297,14 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut: def _membership_for_view(view: TopoView) -> dict[str, Any]: - return parse_membership(dict(view.filter or {}), role=normalize_view_role(view.role)) + # Physical canvases use KIND_DEFAULT_MAX_NODES (2000); role defaults (core=80) + # only apply to custom views. Omitting kind made every empty-filter physical + # canvas silently inherit core=80 and truncate MCP/UI adds early. + return parse_membership( + dict(view.filter or {}), + role=normalize_view_role(view.role), + kind=normalize_view_kind(view.kind), + ) def _fabric_in_hard_scope(db: Session, fn: TopoFabricNode, mem: dict[str, Any]) -> bool: @@ -1104,7 +1111,10 @@ def project_fabric_neighbors_to_view( if peer_ids else {} ) + region_v = str(req.region_folder_id or "").strip() eligible: list[str] = [] + out_of_region_skipped = 0 + out_of_region_sample: list[dict[str, str]] = [] for peer in sorted(peer_ids): if peer in existing: continue @@ -1115,12 +1125,29 @@ def project_fabric_neighbors_to_view( continue if not _fabric_in_hard_scope(db, fn, mem): continue + if region_v and str(fn.region_folder_id or "").strip() != region_v: + out_of_region_skipped += 1 + if len(out_of_region_sample) < 20: + out_of_region_sample.append( + { + "fabric_node_id": peer, + "name": str(fn.name or ""), + "region_folder_id": str(fn.region_folder_id or ""), + } + ) + continue eligible.append(peer) room = max(0, max_nodes - len(existing)) to_add = eligible[:room] truncated = len(eligible) > len(to_add) + def _attach_region_meta(g: TopologyViewGraphOut) -> TopologyViewGraphOut: + if region_v: + g.out_of_region_skipped = out_of_region_skipped + g.out_of_region_sample = out_of_region_sample + return g + if dry_run: if to_add: nested = db.begin_nested() @@ -1137,7 +1164,7 @@ def project_fabric_neighbors_to_view( if truncated: g.truncated = True g.truncate_reason = g.truncate_reason or "membership_cap" - return g + return _attach_region_meta(g) if to_add: _place_fabric_ids_on_view( @@ -1148,22 +1175,26 @@ def project_fabric_neighbors_to_view( if truncated: g.truncated = True g.truncate_reason = g.truncate_reason or "membership_cap" - return g + return _attach_region_meta(g) def populate_view(db: Session, view_id: str, body: ViewPopulateRequest) -> ViewPopulateOut: """Resolve membership candidates and optionally place them on the leaf view.""" view = _get_view_or_404(db, view_id) role = normalize_view_role(view.role) + kind = normalize_view_kind(view.kind) if body.membership is not None: filt = merge_filter_with_membership( - dict(view.filter or {}), role=role, membership=parse_membership( - {"membership": body.membership}, role=role - ) + dict(view.filter or {}), + role=role, + kind=kind, + membership=parse_membership( + {"membership": body.membership}, role=role, kind=kind + ), ) if not body.dry_run: view.filter = filt - mem = parse_membership(dict(view.filter or {}), role=role) + mem = parse_membership(dict(view.filter or {}), role=role, kind=kind) max_nodes = int(mem.get("max_nodes") or 300) hops = int(mem.get("expand_hops") or 1) layer = str((view.filter or {}).get("layer") or "physical").strip() or "physical" diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 34ab836..f26c00b 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -856,14 +856,19 @@ def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut: folder = _get_folder_or_404(db, folder_id) if str(folder.kind or "") == "root": raise HTTPException(status_code=400, detail="view_must_hang_under_region") - if kind == VIEW_KIND_PHYSICAL: - existing = ( - db.query(TopoView) - .filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL) - .first() + existing_physical = ( + db.query(TopoView) + .filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL) + .first() + ) + if kind == VIEW_KIND_PHYSICAL and existing_physical is not None: + raise HTTPException(status_code=400, detail="region_already_has_physical_view") + # One canvas per region/根图: no sibling custom maps — nest sub-regions instead. + if kind == VIEW_KIND_CUSTOM and existing_physical is not None: + raise HTTPException( + status_code=400, + detail="use_create_subregion_folder", ) - if existing is not None: - raise HTTPException(status_code=400, detail="region_already_has_physical_view") filt = merge_filter_with_membership(dict(body.filter or {}), role=role, kind=kind) now = _utcnow() row = TopoView( @@ -923,6 +928,18 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology ) if clash is not None: raise HTTPException(status_code=400, detail="region_already_has_physical_view") + if new_kind == VIEW_KIND_CUSTOM and normalize_view_kind(row.kind) != VIEW_KIND_CUSTOM: + # Do not demote/add custom beside an existing physical canvas. + phys = ( + db.query(TopoView) + .filter( + TopoView.folder_id == row.folder_id, + TopoView.kind == VIEW_KIND_PHYSICAL, + ) + .first() + ) + if phys is not None: + raise HTTPException(status_code=400, detail="use_create_subregion_folder") row.kind = new_kind row.parent_view_id = None if body.filter is not None: diff --git a/tests/test_topology.py b/tests/test_topology.py index 8c446a0..5daaf95 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -7,12 +7,14 @@ import unittest from unittest.mock import patch from uuid import uuid4 -from sqlalchemy import or_ +from sqlalchemy import create_engine, or_ from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool from netx_api import topology_lldp as lldp from netx_api import topology_service as svc -from netx_api.db import Base, SessionLocal, engine +from netx_api.db import Base from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE, TOPOLOGY_NE_SOURCE, WEBCRT_NE_SOURCE from netx_api.models import ( ManagedNE, @@ -30,6 +32,7 @@ from netx_api.topology_schemas import ( TopologyFolderCreate, TopologyPlaceholderCreate, TopologyViewCreate, + TopologyViewUpdate, ViewNodesAdd, ViewPopulateRequest, ViewPositionsPatch, @@ -167,17 +170,24 @@ class DeadlockHelperTests(unittest.TestCase): class FabricTopologyTests(unittest.TestCase): + """Uses an in-memory SQLite engine — never touch the live NETX_DATABASE_URL.""" + @classmethod def setUpClass(cls) -> None: - Base.metadata.create_all(bind=engine) - from netx_api.topology_migrate import ensure_topology_schema - - with engine.begin() as conn: - ensure_topology_schema(conn) + cls.engine = create_engine( + "sqlite+pysqlite:///:memory:", + future=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=cls.engine) + cls.Session = sessionmaker( + bind=cls.engine, autoflush=False, autocommit=False, expire_on_commit=False + ) def setUp(self) -> None: - self.db = SessionLocal() - # Shared SQLite DB across tests — wipe fabric/view state for isolation. + self.db = self.Session() + # Wipe between cases on the isolated in-memory DB only. for model in ( TopoViewEdgeStyle, TopoViewNode, @@ -319,7 +329,7 @@ class FabricTopologyTests(unittest.TestCase): self.assertEqual(action, "skipped_self_loop") def _region(self, name: str = "Test-Region") -> str: - """Return the unique L2「根图」canvas id under a new top-level「根」.""" + """Return the unique L2「根图」canvas folder id under a new top-level「根」.""" top = svc.create_folder( self.db, TopologyFolderCreate(name=name, kind="region") ) @@ -331,6 +341,37 @@ class FabricTopologyTests(unittest.TestCase): ) return child.id if child is not None else top.id + def _physical_view(self, folder_id: str) -> TopoView: + row = ( + self.db.query(TopoView) + .filter( + TopoView.folder_id == folder_id, + TopoView.kind == "physical", + ) + .first() + ) + assert row is not None, f"no physical view on folder {folder_id}" + return row + + def _canvas( + self, + name: str = "Test-Region", + *, + filter: dict | None = None, + role: str | None = None, + ) -> str: + """createTopologyFolder path: return 根图 physical view_id (optional filter patch).""" + folder_id = self._region(name) + view = self._physical_view(folder_id) + if filter is not None or role is not None: + body = TopologyViewUpdate( + filter=filter, + role=role, + ) + svc.update_view(self.db, view.id, body) + self.db.refresh(view) + return view.id + def test_view_crud_and_positions(self) -> None: suffix = uuid4().hex[:8] ne = ManagedNE( @@ -343,18 +384,15 @@ class FabricTopologyTests(unittest.TestCase): self.db.add(ne) self.db.commit() - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"R-{suffix}")), - ) + view_id = self._canvas(f"R-{suffix}") graph = svc.add_nodes_to_view( - self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id]) + self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]) ) self.assertEqual(len(graph.nodes), 1) fid = graph.nodes[0].fabric_node_id graph2 = svc.patch_view_positions( self.db, - view.id, + view_id, ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]), ) self.assertEqual(graph2.nodes[0].x, 120) @@ -363,19 +401,16 @@ class FabricTopologyTests(unittest.TestCase): summary = svc.get_fabric_summary(self.db) self.assertGreaterEqual(summary.node_count, 1) - svc.delete_view(self.db, view.id) + svc.delete_view(self.db, view_id, force=True) self.db.delete(ne) self.db.commit() def test_create_topology_placeholder_on_view(self) -> None: suffix = uuid4().hex[:8] - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"Vph-{suffix}", folder_id=self._region(f"Rph-{suffix}")), - ) + view_id = self._canvas(f"Rph-{suffix}") graph = svc.create_topology_placeholder_on_view( self.db, - view.id, + view_id, TopologyPlaceholderCreate(name=f"SW-{suffix}", ip_address="", x=42, y=77), ) self.assertEqual(len(graph.nodes), 1) @@ -471,25 +506,31 @@ class FabricTopologyTests(unittest.TestCase): 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=root_map.id, - kind="custom", - ), - ) - self.assertEqual(view.folder_id, root_map.id) - self.assertEqual(view.kind, "custom") - self.assertIn("membership", view.filter) + # Sibling custom maps are forbidden — nest a sub-region folder instead. + from fastapi import HTTPException + with self.assertRaises(HTTPException) as ctx: + svc.create_view( + self.db, + TopologyViewCreate( + name="East-Custom", + folder_id=root_map.id, + kind="custom", + ), + ) + self.assertEqual(ctx.exception.detail, "use_create_subregion_folder") + + sub = svc.create_folder( + self.db, + TopologyFolderCreate(name="East-Zone", kind="region", parent_id=root_map.id), + ) 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) 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)) + self.assertEqual(len(rm.views), 1) + self.assertEqual(rm.views[0].kind, "physical") + self.assertTrue(any(c.id == sub.id for c in rm.children)) def test_tree_ne_count_distinct_membership_and_home(self) -> None: """Directory N = distinct fabric ids (membership ∪ region_folder_id); parent unions.""" @@ -610,7 +651,41 @@ class FabricTopologyTests(unittest.TestCase): east = next(c for c in tree2.root.children if c.id == zh_root.id) self.assertEqual(east.children[0].name, "根图") - def test_site_physical_and_custom_flat(self) -> None: + def test_physical_view_default_max_nodes_uses_kind(self) -> None: + """Empty-filter physical canvases must not inherit role core=80.""" + from netx_api.topology_views_graph import _membership_for_view + + root = svc.create_folder( + self.db, TopologyFolderCreate(name="Cap-Site", kind="region", locale="zh") + ) + tree = svc.get_topology_tree(self.db) + assert tree.root is not None + nav = next(c for c in tree.root.children if c.id == root.id) + phys_id = nav.children[0].views[0].id + view = self.db.get(TopoView, phys_id) + assert view is not None + self.assertEqual(view.kind, "physical") + self.assertEqual(view.role, "core") + self.assertEqual(view.filter or {}, {}) + mem = _membership_for_view(view) + self.assertEqual(int(mem.get("max_nodes") or 0), 2000) + + from fastapi import HTTPException + from netx_api.topology_membership import default_membership + + with self.assertRaises(HTTPException) as ctx: + svc.create_view( + self.db, + TopologyViewCreate( + name="Cap-Custom", folder_id=nav.children[0].id, kind="custom" + ), + ) + self.assertEqual(ctx.exception.detail, "use_create_subregion_folder") + self.assertEqual(int(default_membership("core", kind="custom")["max_nodes"]), 80) + + def test_site_rejects_sibling_custom_allows_subregion(self) -> None: + from fastapi import HTTPException + top = svc.create_folder( self.db, TopologyFolderCreate(name="Site-R", kind="region") ) @@ -622,26 +697,30 @@ class FabricTopologyTests(unittest.TestCase): auto_phys = root_map.views[0] self.assertEqual(auto_phys.kind, "physical") - custom = svc.create_view( + with self.assertRaises(HTTPException) as ctx: + svc.create_view( + self.db, + TopologyViewCreate( + name="Custom-A", + folder_id=root_map.id, + kind="custom", + ), + ) + self.assertEqual(ctx.exception.detail, "use_create_subregion_folder") + + sub = svc.create_folder( self.db, - TopologyViewCreate( - name="Custom-A", - folder_id=root_map.id, - kind="custom", - ), + TopologyFolderCreate(name="Site-Zone", kind="region", parent_id=root_map.id), ) 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 == top.id).children[0] - ids = {v.id for v in reg2.views} - self.assertIn(auto_phys.id, ids) - self.assertIn(custom.id, ids) - # physical first - self.assertEqual(reg2.views[0].kind, "physical") + self.assertEqual(len(reg2.views), 1) + self.assertEqual(reg2.views[0].id, auto_phys.id) + self.assertTrue(any(c.id == sub.id for c in reg2.children)) with self.assertRaises(Exception): 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, auto_phys.id, force=True) tree3 = svc.get_topology_tree(self.db) @@ -657,25 +736,24 @@ class FabricTopologyTests(unittest.TestCase): 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( + phys_id = root_map.views[0].id + sub = svc.create_folder( self.db, - TopologyViewCreate( - name="Custom-Del", - folder_id=root_map.id, - kind="custom", + TopologyFolderCreate( + name="Del-Zone", kind="region", parent_id=root_map.id ), ) 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.children[0].views)) + self.assertTrue(any(c.id == sub.id for c in reg.children[0].children)) out = svc.delete_folder(self.db, region.id) self.assertTrue(out.get("deleted")) tree2 = svc.get_topology_tree(self.db) assert tree2.root is not None self.assertFalse(any(c.id == region.id for c in tree2.root.children)) - self.assertIsNone(self.db.get(TopoView, custom.id)) + self.assertIsNone(self.db.get(TopoView, phys_id)) self.assertEqual( self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(), 0, @@ -977,43 +1055,35 @@ class FabricTopologyTests(unittest.TestCase): ) self.db.commit() - view = svc.create_view( - self.db, - TopologyViewCreate( - name=f"Cap-{suffix}", - folder_id=self._region(f"CapR-{suffix}"), - role="core", - filter={ - "membership": { - "expand_hops": 3, - "max_nodes": 2, - "frozen": False, - } - }, - ), + view_id = self._canvas( + f"CapR-{suffix}", + role="core", + filter={ + "membership": { + "expand_hops": 3, + "max_nodes": 2, + "frozen": False, + } + }, ) - svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id])) - g = svc.project_fabric_neighbors_to_view(self.db, view.id) + svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[nes[0].id])) + g = svc.project_fabric_neighbors_to_view(self.db, view_id) self.assertLessEqual(len(g.nodes), 2) self.assertTrue(g.truncated or len(g.nodes) == 2) # Seed-scoped project: expand only from node 0 → only node 1 among line peers. - view2 = svc.create_view( - self.db, - TopologyViewCreate( - name=f"CapSeed-{suffix}", - folder_id=self._region(f"CapSeedR-{suffix}"), - role="core", - filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}}, - ), + view2_id = self._canvas( + f"CapSeedR-{suffix}", + role="core", + filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}}, ) # Place endpoints 0 and 2 (not adjacent); seed from 0 should add 1, not 3. svc.add_nodes_to_view( - self.db, view2.id, ViewNodesAdd(managed_ne_ids=[nes[0].id, nes[2].id]) + self.db, view2_id, ViewNodesAdd(managed_ne_ids=[nes[0].id, nes[2].id]) ) g2 = svc.project_fabric_neighbors_to_view( self.db, - view2.id, + view2_id, ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]), ) ids2 = {n.fabric_node_id for n in g2.nodes} @@ -1025,14 +1095,14 @@ class FabricTopologyTests(unittest.TestCase): # Peers already on canvas must not raise a false truncated banner. g3 = svc.project_fabric_neighbors_to_view( self.db, - view2.id, + view2_id, ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]), ) self.assertFalse(g3.truncated) pop = svc.populate_view( self.db, - view.id, + view_id, ViewPopulateRequest( dry_run=True, membership={"seed_fabric_node_ids": [nodes[0].id], "expand_hops": 3, "max_nodes": 2}, @@ -1041,6 +1111,68 @@ class FabricTopologyTests(unittest.TestCase): self.assertLessEqual(pop.candidate_count, 4) self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2) + def test_project_neighbors_region_folder_filter(self) -> None: + suffix = uuid4().hex[:8] + region_a = self._region(f"RegA-{suffix}") + region_b = self._region(f"RegB-{suffix}") + nes = [] + for i in range(3): + ne = ManagedNE( + id=f"regf-{suffix}-{i}", + name=f"RF{i}-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.11.{(int(suffix[:2], 16) % 200)}.{i + 1}", + ) + self.db.add(ne) + nes.append(ne) + self.db.commit() + nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes] + nodes[0].region_folder_id = region_a + nodes[1].region_folder_id = region_a + nodes[2].region_folder_id = region_b + self.db.commit() + svc.upsert_fabric_edge( + self.db, + a_node_id=nodes[0].id, + b_node_id=nodes[1].id, + a_port="Gi0/0", + b_port="Gi0/0", + source="lldp", + ) + svc.upsert_fabric_edge( + self.db, + a_node_id=nodes[0].id, + b_node_id=nodes[2].id, + a_port="Gi0/1", + b_port="Gi0/1", + source="lldp", + ) + self.db.commit() + + view = self._physical_view(region_a) + svc.update_view( + self.db, + view.id, + TopologyViewUpdate( + role="core", + filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}}, + ), + ) + svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id])) + g = svc.project_fabric_neighbors_to_view( + self.db, + view.id, + ViewProjectNeighborsRequest(region_folder_id=region_a), + ) + ids = {n.fabric_node_id for n in g.nodes} + self.assertIn(nodes[0].id, ids) + self.assertIn(nodes[1].id, ids) + self.assertNotIn(nodes[2].id, ids) + self.assertEqual(g.out_of_region_skipped, 1) + self.assertTrue(g.out_of_region_sample) + self.assertEqual(g.out_of_region_sample[0]["fabric_node_id"], nodes[2].id) + def test_project_neighbors_dry_run_does_not_persist(self) -> None: suffix = uuid4().hex[:8] nes = [] @@ -1067,29 +1199,25 @@ class FabricTopologyTests(unittest.TestCase): source="lldp", ) self.db.commit() - view = svc.create_view( - self.db, - TopologyViewCreate( - name=f"Dry-{suffix}", - folder_id=self._region(f"DryR-{suffix}"), - role="core", - filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}}, - ), + view_id = self._canvas( + f"DryR-{suffix}", + role="core", + filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}}, ) - svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id])) + svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[nes[0].id])) before = ( - self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count() + self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).count() ) g = svc.project_fabric_neighbors_to_view( self.db, - view.id, + view_id, ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id], dry_run=True), ) ids = {n.fabric_node_id for n in g.nodes} self.assertIn(nodes[0].id, ids) self.assertIn(nodes[1].id, ids) after = ( - self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count() + self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).count() ) self.assertEqual(after, before) @@ -1200,13 +1328,10 @@ class FabricTopologyTests(unittest.TestCase): ) self.db.commit() - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"MR-{suffix}")), - ) + view_id = self._canvas(f"MR-{suffix}") graph = svc.add_nodes_to_view( self.db, - view.id, + view_id, ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]), ) self.assertEqual(len(graph.nodes), 4) @@ -1219,10 +1344,10 @@ class FabricTopologyTests(unittest.TestCase): # Re-place inventory nodes only, then project should stay at 2. graph2 = svc.add_nodes_to_view( - self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id]) + self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id]) ) # View may still have stale placements pointing at deleted ids — project cleans. - projected = svc.project_fabric_neighbors_to_view(self.db, view.id) + projected = svc.project_fabric_neighbors_to_view(self.db, view_id) self.assertEqual(len(projected.nodes), 2) labels = sorted((n.name or n.label).upper() for n in projected.nodes) self.assertEqual(labels, ["R1", "R2"]) @@ -1330,12 +1455,9 @@ Management Addresses: self.assertEqual(placeholder.device_type, "generic") self.assertEqual(placeholder.source_ref, "198.51.100.200") - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"PR-{suffix}")), - ) - svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id])) - projected = svc.project_fabric_neighbors_to_view(self.db, view.id) + view_id = self._canvas(f"PR-{suffix}") + svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne_a.id])) + projected = svc.project_fabric_neighbors_to_view(self.db, view_id) self.assertEqual(len(projected.nodes), 2) self.assertGreaterEqual(len(projected.edges), 1) @@ -1979,12 +2101,9 @@ Management Addresses: self.db.add(ne) self.db.commit() region_id = self._region(f"Detach-{suffix}") - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"DV-{suffix}", folder_id=region_id), - ) + view_id = self._physical_view(region_id).id graph = svc.add_nodes_to_view( - self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id]) + self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]) ) fid = graph.nodes[0].fabric_node_id peer = TopoFabricNode( @@ -2013,7 +2132,7 @@ Management Addresses: self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) self.assertEqual( self.db.query(TopoViewNode) - .filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fid) + .filter(TopoViewNode.view_id == view_id, TopoViewNode.fabric_node_id == fid) .count(), 0, ) @@ -2266,10 +2385,7 @@ Management Addresses: suffix = uuid4().hex[:8] region = self._region(f"Bulk-{suffix}") - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"BulkV-{suffix}", folder_id=region), - ) + view_id = self._physical_view(region).id nodes = [] for i in range(5): n = TopoFabricNode( @@ -2293,7 +2409,7 @@ Management Addresses: summary = svc.add_nodes_to_view( self.db, - view.id, + view_id, ViewNodesAdd(keyword="BJ-SW-", limit=3, offset=0, return_graph=False), ) self.assertIsInstance(summary, ViewMutationOut) @@ -2305,7 +2421,7 @@ Management Addresses: more = svc.add_nodes_to_view( self.db, - view.id, + view_id, ViewNodesAdd(keyword="BJ-SW-", limit=10, offset=3, return_graph=False), ) assert isinstance(more, ViewMutationOut) @@ -2314,7 +2430,7 @@ Management Addresses: laid = svc.patch_view_positions( self.db, - view.id, + view_id, ViewPositionsPatch(layout="grid", keyword="BJ-SW-", origin_x=10, origin_y=20, return_graph=False), ) assert isinstance(laid, ViewMutationOut) @@ -2322,7 +2438,7 @@ Management Addresses: removed = svc.remove_view_nodes( self.db, - view.id, + view_id, body=ViewNodesRemove(keyword=f"BJ-SW-{suffix}-1", return_graph=False), ) assert isinstance(removed, ViewMutationOut) @@ -2363,14 +2479,11 @@ Management Addresses: ) self.db.commit() region_id = self._region(f"DelEdge-{suffix}") - view = svc.create_view( - self.db, - TopologyViewCreate(name=f"DE-{suffix}", folder_id=region_id), - ) + view_id = self._physical_view(region_id).id svc.add_nodes_to_view( - self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id]) + self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id]) ) - graph = svc.get_view_graph(self.db, view.id) + graph = svc.get_view_graph(self.db, view_id) self.assertTrue(any(e.id == edge.id for e in graph.edges)) self.assertTrue(any(n.managed_source == "manual" for n in graph.nodes)) @@ -2379,7 +2492,7 @@ Management Addresses: self.assertEqual(out["deleted"], 1) self.db.expire_all() self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) - graph2 = svc.get_view_graph(self.db, view.id) + graph2 = svc.get_view_graph(self.db, view_id) self.assertFalse(any(e.id == edge_id for e in graph2.edges)) diff --git a/web/src/index.css b/web/src/index.css index 69606c9..445fad8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1767,9 +1767,14 @@ pre { .system-page .token-once { padding: 12px 14px; - border: 1px solid #cdd6e2; + border: 1px solid rgba(148, 163, 184, 0.22); border-radius: 8px; - background: #eef2f7; + background: rgba(15, 23, 42, 0.85); + color: #e2e8f0; +} + +.system-page .token-once .panel__hint { + color: #94a3b8; } .system-page .token-once code { @@ -1777,7 +1782,11 @@ pre { margin: 8px 0; word-break: break-all; font-size: 12px; - color: var(--nm-brand); + padding: 8px 10px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: #0b1220; + color: #93c5fd; } .system-page .token-create { @@ -1791,9 +1800,10 @@ pre { flex-direction: column; gap: 10px; padding: 12px 14px; - border: 1px solid #cdd6e2; + border: 1px solid rgba(148, 163, 184, 0.22); border-radius: 8px; - background: #f8fafc; + background: rgba(15, 23, 42, 0.85); + color: #e2e8f0; } .system-page .token-scopes__head { @@ -1813,7 +1823,7 @@ pre { align-items: center; gap: 8px; font-size: 13px; - color: #334155; + color: #cbd5e1; } .system-page .token-scopes__grid { @@ -1831,7 +1841,7 @@ pre { align-items: center; gap: 6px; font-size: 12px; - color: #334155; + color: #cbd5e1; white-space: nowrap; } @@ -1841,7 +1851,7 @@ pre { text-overflow: ellipsis; white-space: nowrap; font-size: 12px; - color: #475569; + color: #94a3b8; } .token-page__title { diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index dc73764..55df30c 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1574,7 +1574,11 @@ export function TopologyPage() { } return views.find((v) => !isWorldFlatViewName(v.name)) || null; } - return views[0] || null; + // Region / 根图: the physical view is the canvas. Custom siblings are rare + // extras — never prefer them over physical for the default open target. + const physical = + views.find((v) => String(v.kind) === "physical") || null; + return physical || views[0] || null; }, []); const goRegion = useCallback( @@ -2084,11 +2088,12 @@ export function TopologyPage() { folder.external_ref === "ume:world" || folder.name === "UME World"; const drillFolder = isWorldDrillFolder(folder); const umeNav = isUmeWorldNavFolder(folder); - // Region === canvas: hide physical/custom view rows; only child regions. + // Region === canvas: hide the physical primary row (region click opens it), + // but keep sibling custom canvases visible (e.g. MCP LPG-Full next to 根图). // UME World container: only「世界地图」as a sibling view. const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || "")); const visibleViews = canvasRegion - ? [] + ? (folder.views || []).filter((v) => String(v.kind) !== "physical") : containerFolder ? (folder.views || []).filter((v) => isWorldFlatViewName(v.name)) : folder.views || []; diff --git a/web/src/pages/topology/TopologyReactFlowView.tsx b/web/src/pages/topology/TopologyReactFlowView.tsx index 7aec26b..e6a0d75 100644 --- a/web/src/pages/topology/TopologyReactFlowView.tsx +++ b/web/src/pages/topology/TopologyReactFlowView.tsx @@ -360,7 +360,8 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }} defaultEdgeOptions={{ type: "straight", labelShowBg: false }} proOptions={{ hideAttribution: true }} - minZoom={isWorldFlatCanvas ? 0.002 : 0.05} + // Composed metro canvases can span 40k–70k; default 0.05 clips fitView. + minZoom={isWorldFlatCanvas || nodes.length >= 400 ? 0.002 : 0.05} maxZoom={isWorldFlatCanvas ? 12 : 4} nodesDraggable={ isWorldFlatCanvas