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,
}
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 file

@ -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)

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]:
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"

View file

@ -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 = (
existing_physical = (
db.query(TopoView)
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.first()
)
if existing is not None:
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",
)
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:

View file

@ -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,7 +506,11 @@ class FabricTopologyTests(unittest.TestCase):
self.assertEqual(len(root_map.views), 1)
self.assertEqual(root_map.views[0].name, "根图")
view = svc.create_view(
# 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",
@ -479,17 +518,19 @@ class FabricTopologyTests(unittest.TestCase):
kind="custom",
),
)
self.assertEqual(view.folder_id, root_map.id)
self.assertEqual(view.kind, "custom")
self.assertIn("membership", view.filter)
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,7 +697,8 @@ 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",
@ -630,18 +706,21 @@ class FabricTopologyTests(unittest.TestCase):
kind="custom",
),
)
self.assertEqual(ctx.exception.detail, "use_create_subregion_folder")
sub = svc.create_folder(
self.db,
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,11 +1055,8 @@ 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}"),
view_id = self._canvas(
f"CapR-{suffix}",
role="core",
filter={
"membership": {
@ -990,30 +1065,25 @@ class FabricTopologyTests(unittest.TestCase):
"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}"),
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}"),
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))

View file

@ -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 {

View file

@ -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 || [];

View file

@ -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