Raise physical canvas capacity and keep region==canvas rules consistent.

Pass view kind into membership so metro maps are not capped at core=80, add region-scoped neighbor projection, and align UI/tests with one physical canvas per region.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-09 23:23:09 +08:00
parent a9a45d39d4
commit 75d03f5f9b
8 changed files with 342 additions and 157 deletions

View file

@ -19,7 +19,8 @@ ROLE_DEFAULT_MAX_NODES = {
VIEW_ROLE_ACCESS: 300, VIEW_ROLE_ACCESS: 300,
} }
KIND_DEFAULT_MAX_NODES = { 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, VIEW_KIND_CUSTOM: 300,
} }

View file

@ -253,12 +253,16 @@ class ViewProjectNeighborsRequest(BaseModel):
When provided, expand only from those seeds (resolved to fabric nodes that When provided, expand only from those seeds (resolved to fabric nodes that
are already placed on the view). 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 dry_run=True: return the would-be graph without writing placements (canvas
can apply locally and persist on Save). can apply locally and persist on Save).
""" """
seed_fabric_node_ids: list[str] = Field(default_factory=list) seed_fabric_node_ids: list[str] = Field(default_factory=list)
managed_ne_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 dry_run: bool = False
@ -338,6 +342,9 @@ class TopologyViewGraphOut(BaseModel):
truncated: bool = False truncated: bool = False
truncate_reason: str = "" truncate_reason: str = ""
outside_peers: list[dict[str, str]] = Field(default_factory=list) 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 world_transform: WorldTransformOut | None = None
# Overview starfield: all (or capped) display coords; RF nodes stay empty far out. # Overview starfield: all (or capped) display coords; RF nodes stay empty far out.
scatter: list[WorldScatterPointOut] = Field(default_factory=list) scatter: list[WorldScatterPointOut] = Field(default_factory=list)

View file

@ -297,7 +297,14 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
def _membership_for_view(view: TopoView) -> dict[str, Any]: 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: 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 if peer_ids
else {} else {}
) )
region_v = str(req.region_folder_id or "").strip()
eligible: list[str] = [] eligible: list[str] = []
out_of_region_skipped = 0
out_of_region_sample: list[dict[str, str]] = []
for peer in sorted(peer_ids): for peer in sorted(peer_ids):
if peer in existing: if peer in existing:
continue continue
@ -1115,12 +1125,29 @@ def project_fabric_neighbors_to_view(
continue continue
if not _fabric_in_hard_scope(db, fn, mem): if not _fabric_in_hard_scope(db, fn, mem):
continue 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) eligible.append(peer)
room = max(0, max_nodes - len(existing)) room = max(0, max_nodes - len(existing))
to_add = eligible[:room] to_add = eligible[:room]
truncated = len(eligible) > len(to_add) 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 dry_run:
if to_add: if to_add:
nested = db.begin_nested() nested = db.begin_nested()
@ -1137,7 +1164,7 @@ def project_fabric_neighbors_to_view(
if truncated: if truncated:
g.truncated = True g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap" g.truncate_reason = g.truncate_reason or "membership_cap"
return g return _attach_region_meta(g)
if to_add: if to_add:
_place_fabric_ids_on_view( _place_fabric_ids_on_view(
@ -1148,22 +1175,26 @@ def project_fabric_neighbors_to_view(
if truncated: if truncated:
g.truncated = True g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap" 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: def populate_view(db: Session, view_id: str, body: ViewPopulateRequest) -> ViewPopulateOut:
"""Resolve membership candidates and optionally place them on the leaf view.""" """Resolve membership candidates and optionally place them on the leaf view."""
view = _get_view_or_404(db, view_id) view = _get_view_or_404(db, view_id)
role = normalize_view_role(view.role) role = normalize_view_role(view.role)
kind = normalize_view_kind(view.kind)
if body.membership is not None: if body.membership is not None:
filt = merge_filter_with_membership( filt = merge_filter_with_membership(
dict(view.filter or {}), role=role, membership=parse_membership( dict(view.filter or {}),
{"membership": body.membership}, role=role role=role,
) kind=kind,
membership=parse_membership(
{"membership": body.membership}, role=role, kind=kind
),
) )
if not body.dry_run: if not body.dry_run:
view.filter = filt 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) max_nodes = int(mem.get("max_nodes") or 300)
hops = int(mem.get("expand_hops") or 1) hops = int(mem.get("expand_hops") or 1)
layer = str((view.filter or {}).get("layer") or "physical").strip() or "physical" layer = str((view.filter or {}).get("layer") or "physical").strip() or "physical"

View file

@ -856,14 +856,19 @@ def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut:
folder = _get_folder_or_404(db, folder_id) folder = _get_folder_or_404(db, folder_id)
if str(folder.kind or "") == "root": if str(folder.kind or "") == "root":
raise HTTPException(status_code=400, detail="view_must_hang_under_region") raise HTTPException(status_code=400, detail="view_must_hang_under_region")
if kind == VIEW_KIND_PHYSICAL: existing_physical = (
existing = ( db.query(TopoView)
db.query(TopoView) .filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL) .first()
.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) filt = merge_filter_with_membership(dict(body.filter or {}), role=role, kind=kind)
now = _utcnow() now = _utcnow()
row = TopoView( row = TopoView(
@ -923,6 +928,18 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology
) )
if clash is not None: if clash is not None:
raise HTTPException(status_code=400, detail="region_already_has_physical_view") 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.kind = new_kind
row.parent_view_id = None row.parent_view_id = None
if body.filter is not None: if body.filter is not None:

View file

@ -7,12 +7,14 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import or_ from sqlalchemy import create_engine, or_
from sqlalchemy.exc import OperationalError 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_lldp as lldp
from netx_api import topology_service as svc 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.device_types import LLDP_DISCOVERED_NE_SOURCE, TOPOLOGY_NE_SOURCE, WEBCRT_NE_SOURCE
from netx_api.models import ( from netx_api.models import (
ManagedNE, ManagedNE,
@ -30,6 +32,7 @@ from netx_api.topology_schemas import (
TopologyFolderCreate, TopologyFolderCreate,
TopologyPlaceholderCreate, TopologyPlaceholderCreate,
TopologyViewCreate, TopologyViewCreate,
TopologyViewUpdate,
ViewNodesAdd, ViewNodesAdd,
ViewPopulateRequest, ViewPopulateRequest,
ViewPositionsPatch, ViewPositionsPatch,
@ -167,17 +170,24 @@ class DeadlockHelperTests(unittest.TestCase):
class FabricTopologyTests(unittest.TestCase): class FabricTopologyTests(unittest.TestCase):
"""Uses an in-memory SQLite engine — never touch the live NETX_DATABASE_URL."""
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
Base.metadata.create_all(bind=engine) cls.engine = create_engine(
from netx_api.topology_migrate import ensure_topology_schema "sqlite+pysqlite:///:memory:",
future=True,
with engine.begin() as conn: connect_args={"check_same_thread": False},
ensure_topology_schema(conn) 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: def setUp(self) -> None:
self.db = SessionLocal() self.db = self.Session()
# Shared SQLite DB across tests — wipe fabric/view state for isolation. # Wipe between cases on the isolated in-memory DB only.
for model in ( for model in (
TopoViewEdgeStyle, TopoViewEdgeStyle,
TopoViewNode, TopoViewNode,
@ -319,7 +329,7 @@ class FabricTopologyTests(unittest.TestCase):
self.assertEqual(action, "skipped_self_loop") self.assertEqual(action, "skipped_self_loop")
def _region(self, name: str = "Test-Region") -> str: 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( top = svc.create_folder(
self.db, TopologyFolderCreate(name=name, kind="region") 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 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: def test_view_crud_and_positions(self) -> None:
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]
ne = ManagedNE( ne = ManagedNE(
@ -343,18 +384,15 @@ class FabricTopologyTests(unittest.TestCase):
self.db.add(ne) self.db.add(ne)
self.db.commit() self.db.commit()
view = svc.create_view( view_id = self._canvas(f"R-{suffix}")
self.db,
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"R-{suffix}")),
)
graph = svc.add_nodes_to_view( 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) self.assertEqual(len(graph.nodes), 1)
fid = graph.nodes[0].fabric_node_id fid = graph.nodes[0].fabric_node_id
graph2 = svc.patch_view_positions( graph2 = svc.patch_view_positions(
self.db, self.db,
view.id, view_id,
ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]), ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]),
) )
self.assertEqual(graph2.nodes[0].x, 120) self.assertEqual(graph2.nodes[0].x, 120)
@ -363,19 +401,16 @@ class FabricTopologyTests(unittest.TestCase):
summary = svc.get_fabric_summary(self.db) summary = svc.get_fabric_summary(self.db)
self.assertGreaterEqual(summary.node_count, 1) 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.delete(ne)
self.db.commit() self.db.commit()
def test_create_topology_placeholder_on_view(self) -> None: def test_create_topology_placeholder_on_view(self) -> None:
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]
view = svc.create_view( view_id = self._canvas(f"Rph-{suffix}")
self.db,
TopologyViewCreate(name=f"Vph-{suffix}", folder_id=self._region(f"Rph-{suffix}")),
)
graph = svc.create_topology_placeholder_on_view( graph = svc.create_topology_placeholder_on_view(
self.db, self.db,
view.id, view_id,
TopologyPlaceholderCreate(name=f"SW-{suffix}", ip_address="", x=42, y=77), TopologyPlaceholderCreate(name=f"SW-{suffix}", ip_address="", x=42, y=77),
) )
self.assertEqual(len(graph.nodes), 1) self.assertEqual(len(graph.nodes), 1)
@ -471,25 +506,31 @@ class FabricTopologyTests(unittest.TestCase):
self.assertEqual(len(root_map.views), 1) self.assertEqual(len(root_map.views), 1)
self.assertEqual(root_map.views[0].name, "根图") self.assertEqual(root_map.views[0].name, "根图")
view = svc.create_view( # Sibling custom maps are forbidden — nest a sub-region folder instead.
self.db, from fastapi import HTTPException
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)
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) tree2 = svc.get_topology_tree(self.db)
assert tree2.root is not None assert tree2.root is not None
east = next(c for c in tree2.root.children if c.id == region.id) east = next(c for c in tree2.root.children if c.id == region.id)
rm = east.children[0] rm = east.children[0]
self.assertTrue(any(v.id == view.id for v in rm.views)) self.assertEqual(len(rm.views), 1)
self.assertEqual(len(rm.views), 2) self.assertEqual(rm.views[0].kind, "physical")
self.assertTrue(all(not getattr(v, "children", None) for v in rm.views)) self.assertTrue(any(c.id == sub.id for c in rm.children))
def test_tree_ne_count_distinct_membership_and_home(self) -> None: def test_tree_ne_count_distinct_membership_and_home(self) -> None:
"""Directory N = distinct fabric ids (membership ∪ region_folder_id); parent unions.""" """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) east = next(c for c in tree2.root.children if c.id == zh_root.id)
self.assertEqual(east.children[0].name, "根图") 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( top = svc.create_folder(
self.db, TopologyFolderCreate(name="Site-R", kind="region") self.db, TopologyFolderCreate(name="Site-R", kind="region")
) )
@ -622,26 +697,30 @@ class FabricTopologyTests(unittest.TestCase):
auto_phys = root_map.views[0] auto_phys = root_map.views[0]
self.assertEqual(auto_phys.kind, "physical") 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, self.db,
TopologyViewCreate( TopologyFolderCreate(name="Site-Zone", kind="region", parent_id=root_map.id),
name="Custom-A",
folder_id=root_map.id,
kind="custom",
),
) )
tree2 = svc.get_topology_tree(self.db) tree2 = svc.get_topology_tree(self.db)
assert tree2.root is not None assert tree2.root is not None
reg2 = next(c for c in tree2.root.children if c.id == top.id).children[0] 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.assertEqual(len(reg2.views), 1)
self.assertIn(auto_phys.id, ids) self.assertEqual(reg2.views[0].id, auto_phys.id)
self.assertIn(custom.id, ids) self.assertTrue(any(c.id == sub.id for c in reg2.children))
# physical first
self.assertEqual(reg2.views[0].kind, "physical")
with self.assertRaises(Exception): with self.assertRaises(Exception):
svc.delete_view(self.db, auto_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 # force-delete physical does not recreate another map
svc.delete_view(self.db, auto_phys.id, force=True) svc.delete_view(self.db, auto_phys.id, force=True)
tree3 = svc.get_topology_tree(self.db) tree3 = svc.get_topology_tree(self.db)
@ -657,25 +736,24 @@ class FabricTopologyTests(unittest.TestCase):
assert tree0.root is not None assert tree0.root is not None
nav = next(c for c in tree0.root.children if c.id == region.id) nav = next(c for c in tree0.root.children if c.id == region.id)
root_map = nav.children[0] root_map = nav.children[0]
custom = svc.create_view( phys_id = root_map.views[0].id
sub = svc.create_folder(
self.db, self.db,
TopologyViewCreate( TopologyFolderCreate(
name="Custom-Del", name="Del-Zone", kind="region", parent_id=root_map.id
folder_id=root_map.id,
kind="custom",
), ),
) )
tree = svc.get_topology_tree(self.db) tree = svc.get_topology_tree(self.db)
assert tree.root is not None assert tree.root is not None
reg = next(c for c in tree.root.children if c.id == region.id) 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) out = svc.delete_folder(self.db, region.id)
self.assertTrue(out.get("deleted")) self.assertTrue(out.get("deleted"))
tree2 = svc.get_topology_tree(self.db) tree2 = svc.get_topology_tree(self.db)
assert tree2.root is not None assert tree2.root is not None
self.assertFalse(any(c.id == region.id for c in tree2.root.children)) 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.assertEqual(
self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(), self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(),
0, 0,
@ -977,43 +1055,35 @@ class FabricTopologyTests(unittest.TestCase):
) )
self.db.commit() self.db.commit()
view = svc.create_view( view_id = self._canvas(
self.db, f"CapR-{suffix}",
TopologyViewCreate( role="core",
name=f"Cap-{suffix}", filter={
folder_id=self._region(f"CapR-{suffix}"), "membership": {
role="core", "expand_hops": 3,
filter={ "max_nodes": 2,
"membership": { "frozen": False,
"expand_hops": 3, }
"max_nodes": 2, },
"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]))
g = svc.project_fabric_neighbors_to_view(self.db, view.id) g = svc.project_fabric_neighbors_to_view(self.db, view_id)
self.assertLessEqual(len(g.nodes), 2) self.assertLessEqual(len(g.nodes), 2)
self.assertTrue(g.truncated or 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. # Seed-scoped project: expand only from node 0 → only node 1 among line peers.
view2 = svc.create_view( view2_id = self._canvas(
self.db, f"CapSeedR-{suffix}",
TopologyViewCreate( role="core",
name=f"CapSeed-{suffix}", filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
folder_id=self._region(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. # Place endpoints 0 and 2 (not adjacent); seed from 0 should add 1, not 3.
svc.add_nodes_to_view( 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( g2 = svc.project_fabric_neighbors_to_view(
self.db, self.db,
view2.id, view2_id,
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]), ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]),
) )
ids2 = {n.fabric_node_id for n in g2.nodes} 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. # Peers already on canvas must not raise a false truncated banner.
g3 = svc.project_fabric_neighbors_to_view( g3 = svc.project_fabric_neighbors_to_view(
self.db, self.db,
view2.id, view2_id,
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]), ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]),
) )
self.assertFalse(g3.truncated) self.assertFalse(g3.truncated)
pop = svc.populate_view( pop = svc.populate_view(
self.db, self.db,
view.id, view_id,
ViewPopulateRequest( ViewPopulateRequest(
dry_run=True, dry_run=True,
membership={"seed_fabric_node_ids": [nodes[0].id], "expand_hops": 3, "max_nodes": 2}, 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.assertLessEqual(pop.candidate_count, 4)
self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2) 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: def test_project_neighbors_dry_run_does_not_persist(self) -> None:
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]
nes = [] nes = []
@ -1067,29 +1199,25 @@ class FabricTopologyTests(unittest.TestCase):
source="lldp", source="lldp",
) )
self.db.commit() self.db.commit()
view = svc.create_view( view_id = self._canvas(
self.db, f"DryR-{suffix}",
TopologyViewCreate( role="core",
name=f"Dry-{suffix}", filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
folder_id=self._region(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 = ( 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( g = svc.project_fabric_neighbors_to_view(
self.db, self.db,
view.id, view_id,
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id], dry_run=True), ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id], dry_run=True),
) )
ids = {n.fabric_node_id for n in g.nodes} ids = {n.fabric_node_id for n in g.nodes}
self.assertIn(nodes[0].id, ids) self.assertIn(nodes[0].id, ids)
self.assertIn(nodes[1].id, ids) self.assertIn(nodes[1].id, ids)
after = ( 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) self.assertEqual(after, before)
@ -1200,13 +1328,10 @@ class FabricTopologyTests(unittest.TestCase):
) )
self.db.commit() self.db.commit()
view = svc.create_view( view_id = self._canvas(f"MR-{suffix}")
self.db,
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"MR-{suffix}")),
)
graph = svc.add_nodes_to_view( graph = svc.add_nodes_to_view(
self.db, self.db,
view.id, view_id,
ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]), ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]),
) )
self.assertEqual(len(graph.nodes), 4) 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. # Re-place inventory nodes only, then project should stay at 2.
graph2 = svc.add_nodes_to_view( 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. # 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) self.assertEqual(len(projected.nodes), 2)
labels = sorted((n.name or n.label).upper() for n in projected.nodes) labels = sorted((n.name or n.label).upper() for n in projected.nodes)
self.assertEqual(labels, ["R1", "R2"]) self.assertEqual(labels, ["R1", "R2"])
@ -1330,12 +1455,9 @@ Management Addresses:
self.assertEqual(placeholder.device_type, "generic") self.assertEqual(placeholder.device_type, "generic")
self.assertEqual(placeholder.source_ref, "198.51.100.200") self.assertEqual(placeholder.source_ref, "198.51.100.200")
view = svc.create_view( view_id = self._canvas(f"PR-{suffix}")
self.db, svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne_a.id]))
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"PR-{suffix}")), projected = svc.project_fabric_neighbors_to_view(self.db, view_id)
)
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.assertEqual(len(projected.nodes), 2)
self.assertGreaterEqual(len(projected.edges), 1) self.assertGreaterEqual(len(projected.edges), 1)
@ -1979,12 +2101,9 @@ Management Addresses:
self.db.add(ne) self.db.add(ne)
self.db.commit() self.db.commit()
region_id = self._region(f"Detach-{suffix}") region_id = self._region(f"Detach-{suffix}")
view = svc.create_view( view_id = self._physical_view(region_id).id
self.db,
TopologyViewCreate(name=f"DV-{suffix}", folder_id=region_id),
)
graph = svc.add_nodes_to_view( 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 fid = graph.nodes[0].fabric_node_id
peer = TopoFabricNode( peer = TopoFabricNode(
@ -2013,7 +2132,7 @@ Management Addresses:
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
self.assertEqual( self.assertEqual(
self.db.query(TopoViewNode) 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(), .count(),
0, 0,
) )
@ -2266,10 +2385,7 @@ Management Addresses:
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]
region = self._region(f"Bulk-{suffix}") region = self._region(f"Bulk-{suffix}")
view = svc.create_view( view_id = self._physical_view(region).id
self.db,
TopologyViewCreate(name=f"BulkV-{suffix}", folder_id=region),
)
nodes = [] nodes = []
for i in range(5): for i in range(5):
n = TopoFabricNode( n = TopoFabricNode(
@ -2293,7 +2409,7 @@ Management Addresses:
summary = svc.add_nodes_to_view( summary = svc.add_nodes_to_view(
self.db, self.db,
view.id, view_id,
ViewNodesAdd(keyword="BJ-SW-", limit=3, offset=0, return_graph=False), ViewNodesAdd(keyword="BJ-SW-", limit=3, offset=0, return_graph=False),
) )
self.assertIsInstance(summary, ViewMutationOut) self.assertIsInstance(summary, ViewMutationOut)
@ -2305,7 +2421,7 @@ Management Addresses:
more = svc.add_nodes_to_view( more = svc.add_nodes_to_view(
self.db, self.db,
view.id, view_id,
ViewNodesAdd(keyword="BJ-SW-", limit=10, offset=3, return_graph=False), ViewNodesAdd(keyword="BJ-SW-", limit=10, offset=3, return_graph=False),
) )
assert isinstance(more, ViewMutationOut) assert isinstance(more, ViewMutationOut)
@ -2314,7 +2430,7 @@ Management Addresses:
laid = svc.patch_view_positions( laid = svc.patch_view_positions(
self.db, self.db,
view.id, view_id,
ViewPositionsPatch(layout="grid", keyword="BJ-SW-", origin_x=10, origin_y=20, return_graph=False), ViewPositionsPatch(layout="grid", keyword="BJ-SW-", origin_x=10, origin_y=20, return_graph=False),
) )
assert isinstance(laid, ViewMutationOut) assert isinstance(laid, ViewMutationOut)
@ -2322,7 +2438,7 @@ Management Addresses:
removed = svc.remove_view_nodes( removed = svc.remove_view_nodes(
self.db, self.db,
view.id, view_id,
body=ViewNodesRemove(keyword=f"BJ-SW-{suffix}-1", return_graph=False), body=ViewNodesRemove(keyword=f"BJ-SW-{suffix}-1", return_graph=False),
) )
assert isinstance(removed, ViewMutationOut) assert isinstance(removed, ViewMutationOut)
@ -2363,14 +2479,11 @@ Management Addresses:
) )
self.db.commit() self.db.commit()
region_id = self._region(f"DelEdge-{suffix}") region_id = self._region(f"DelEdge-{suffix}")
view = svc.create_view( view_id = self._physical_view(region_id).id
self.db,
TopologyViewCreate(name=f"DE-{suffix}", folder_id=region_id),
)
svc.add_nodes_to_view( 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(e.id == edge.id for e in graph.edges))
self.assertTrue(any(n.managed_source == "manual" for n in graph.nodes)) self.assertTrue(any(n.managed_source == "manual" for n in graph.nodes))
@ -2379,7 +2492,7 @@ Management Addresses:
self.assertEqual(out["deleted"], 1) self.assertEqual(out["deleted"], 1)
self.db.expire_all() self.db.expire_all()
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) 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)) self.assertFalse(any(e.id == edge_id for e in graph2.edges))

View file

@ -1767,9 +1767,14 @@ pre {
.system-page .token-once { .system-page .token-once {
padding: 12px 14px; padding: 12px 14px;
border: 1px solid #cdd6e2; border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px; 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 { .system-page .token-once code {
@ -1777,7 +1782,11 @@ pre {
margin: 8px 0; margin: 8px 0;
word-break: break-all; word-break: break-all;
font-size: 12px; 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 { .system-page .token-create {
@ -1791,9 +1800,10 @@ pre {
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid #cdd6e2; border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px; border-radius: 8px;
background: #f8fafc; background: rgba(15, 23, 42, 0.85);
color: #e2e8f0;
} }
.system-page .token-scopes__head { .system-page .token-scopes__head {
@ -1813,7 +1823,7 @@ pre {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
color: #334155; color: #cbd5e1;
} }
.system-page .token-scopes__grid { .system-page .token-scopes__grid {
@ -1831,7 +1841,7 @@ pre {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 12px; font-size: 12px;
color: #334155; color: #cbd5e1;
white-space: nowrap; white-space: nowrap;
} }
@ -1841,7 +1851,7 @@ pre {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 12px; font-size: 12px;
color: #475569; color: #94a3b8;
} }
.token-page__title { .token-page__title {

View file

@ -1574,7 +1574,11 @@ export function TopologyPage() {
} }
return views.find((v) => !isWorldFlatViewName(v.name)) || null; 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( const goRegion = useCallback(
@ -2084,11 +2088,12 @@ export function TopologyPage() {
folder.external_ref === "ume:world" || folder.name === "UME World"; folder.external_ref === "ume:world" || folder.name === "UME World";
const drillFolder = isWorldDrillFolder(folder); const drillFolder = isWorldDrillFolder(folder);
const umeNav = isUmeWorldNavFolder(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. // UME World container: only「世界地图」as a sibling view.
const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || "")); const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || ""));
const visibleViews = canvasRegion const visibleViews = canvasRegion
? [] ? (folder.views || []).filter((v) => String(v.kind) !== "physical")
: containerFolder : containerFolder
? (folder.views || []).filter((v) => isWorldFlatViewName(v.name)) ? (folder.views || []).filter((v) => isWorldFlatViewName(v.name))
: folder.views || []; : folder.views || [];

View file

@ -360,7 +360,8 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }} connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }} defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
proOptions={{ hideAttribution: true }} 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} maxZoom={isWorldFlatCanvas ? 12 : 4}
nodesDraggable={ nodesDraggable={
isWorldFlatCanvas isWorldFlatCanvas