mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 04:00:47 +08:00
Heal legacy single-map roots into 根/根图 on tree bootstrap.
Old installs hung the canvas on the top-level region; migrate those views under a system root map so tree load and create-root keep working. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
990798987b
commit
35a3980dc0
2 changed files with 227 additions and 53 deletions
|
|
@ -164,8 +164,133 @@ def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = T
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_manual_root_map(
|
||||||
|
db: Session, top: TopoFolder, *, now: Any | None = None
|
||||||
|
) -> tuple[TopoFolder, bool]:
|
||||||
|
"""Ensure a top-level manual「根」has unique L2「根图」; migrate legacy L1 canvas onto it.
|
||||||
|
|
||||||
|
Pre-architecture shape: region === canvas (physical view hangs on the L1 folder).
|
||||||
|
Current shape: L1 nav-only + system L2「根图」holds the canvas (and any prior L2 kids).
|
||||||
|
"""
|
||||||
|
stamp = now or _utcnow()
|
||||||
|
changed = False
|
||||||
|
kids = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||||||
|
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
root_map = next((k for k in kids if str(k.name or "") == MANUAL_ROOT_MAP_NAME), None)
|
||||||
|
if root_map is None:
|
||||||
|
root_map = TopoFolder(
|
||||||
|
id=uuid4().hex,
|
||||||
|
parent_id=top.id,
|
||||||
|
kind="region",
|
||||||
|
name=MANUAL_ROOT_MAP_NAME,
|
||||||
|
sort_order=0,
|
||||||
|
is_system=True,
|
||||||
|
created_at=stamp,
|
||||||
|
updated_at=stamp,
|
||||||
|
)
|
||||||
|
db.add(root_map)
|
||||||
|
db.flush()
|
||||||
|
changed = True
|
||||||
|
for kid in kids:
|
||||||
|
kid.parent_id = root_map.id
|
||||||
|
kid.updated_at = stamp
|
||||||
|
changed = True
|
||||||
|
elif not bool(root_map.is_system):
|
||||||
|
root_map.is_system = True
|
||||||
|
root_map.updated_at = stamp
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
l1_views = db.query(TopoView).filter(TopoView.folder_id == top.id).all()
|
||||||
|
for v in l1_views:
|
||||||
|
v.folder_id = root_map.id
|
||||||
|
v.updated_at = stamp
|
||||||
|
changed = True
|
||||||
|
if l1_views:
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
has_phys = (
|
||||||
|
db.query(TopoView.id)
|
||||||
|
.filter(TopoView.folder_id == root_map.id, TopoView.kind == VIEW_KIND_PHYSICAL)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not has_phys:
|
||||||
|
db.add(
|
||||||
|
TopoView(
|
||||||
|
id=uuid4().hex,
|
||||||
|
folder_id=root_map.id,
|
||||||
|
kind=VIEW_KIND_PHYSICAL,
|
||||||
|
role="core",
|
||||||
|
name=MANUAL_ROOT_MAP_NAME,
|
||||||
|
remark="",
|
||||||
|
sort_order=0,
|
||||||
|
filter={},
|
||||||
|
viewport={},
|
||||||
|
created_at=stamp,
|
||||||
|
updated_at=stamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
return root_map, changed
|
||||||
|
|
||||||
|
|
||||||
|
def _heal_manual_root_canvases(db: Session, root: TopoFolder) -> bool:
|
||||||
|
"""Adopt orphan regions and upgrade legacy single-map roots to 根/根图."""
|
||||||
|
from .ume_topology_world import is_ume_world_container
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
changed = False
|
||||||
|
# Floating regions (no parent) from older installs — hang under system root.
|
||||||
|
for orphan in (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(
|
||||||
|
TopoFolder.kind == "region",
|
||||||
|
or_(TopoFolder.parent_id.is_(None), TopoFolder.parent_id == ""),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
orphan.parent_id = root.id
|
||||||
|
orphan.updated_at = now
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
tops = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.parent_id == root.id, TopoFolder.kind == "region")
|
||||||
|
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for top in tops:
|
||||||
|
if is_ume_world_container(top):
|
||||||
|
continue
|
||||||
|
kids = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
has_root_map = any(str(k.name or "") == MANUAL_ROOT_MAP_NAME for k in kids)
|
||||||
|
l1_view_cnt = (
|
||||||
|
db.query(func.count(TopoView.id)).filter(TopoView.folder_id == top.id).scalar() or 0
|
||||||
|
)
|
||||||
|
# Already correct: unique 根图, no stray L1 views.
|
||||||
|
if has_root_map and int(l1_view_cnt) == 0:
|
||||||
|
# Still mark 根图 system if needed.
|
||||||
|
rm = next(k for k in kids if str(k.name or "") == MANUAL_ROOT_MAP_NAME)
|
||||||
|
if not bool(rm.is_system):
|
||||||
|
rm.is_system = True
|
||||||
|
rm.updated_at = now
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
_, did = _ensure_manual_root_map(db, top, now=now)
|
||||||
|
changed = changed or did
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
"""Ensure hidden system root; flatten legacy nesting (once when needed)."""
|
"""Ensure hidden system root; heal legacy Unassigned / single-map roots when needed."""
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
root = (
|
root = (
|
||||||
db.query(TopoFolder)
|
db.query(TopoFolder)
|
||||||
|
|
@ -189,7 +314,7 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"root_id": root.id}
|
return {"root_id": root.id}
|
||||||
|
|
||||||
# Hot path: root already exists. Only touch legacy Unassigned rows when present.
|
dirty = False
|
||||||
legacy = (
|
legacy = (
|
||||||
db.query(TopoFolder)
|
db.query(TopoFolder)
|
||||||
.filter(
|
.filter(
|
||||||
|
|
@ -198,19 +323,29 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
if not legacy:
|
|
||||||
return {"root_id": root.id}
|
|
||||||
|
|
||||||
for folder in legacy:
|
for folder in legacy:
|
||||||
view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count()
|
view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count()
|
||||||
if view_cnt == 0:
|
if view_cnt == 0:
|
||||||
db.delete(folder)
|
db.delete(folder)
|
||||||
|
dirty = True
|
||||||
elif bool(folder.is_system):
|
elif bool(folder.is_system):
|
||||||
folder.is_system = False
|
folder.is_system = False
|
||||||
folder.updated_at = now
|
folder.updated_at = now
|
||||||
|
dirty = True
|
||||||
|
|
||||||
# One-time-ish normalize for views hanging under cleaned legacy folders.
|
# Cheap normalize only for rows that still look pre-migration.
|
||||||
for v in db.query(TopoView).all():
|
stale_views = (
|
||||||
|
db.query(TopoView)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
TopoView.parent_view_id.isnot(None),
|
||||||
|
TopoView.role.is_(None),
|
||||||
|
TopoView.role == "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for v in stale_views:
|
||||||
changed = False
|
changed = False
|
||||||
if v.parent_view_id:
|
if v.parent_view_id:
|
||||||
v.parent_view_id = None
|
v.parent_view_id = None
|
||||||
|
|
@ -230,7 +365,12 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
v.updated_at = now
|
v.updated_at = now
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
if _heal_manual_root_canvases(db, root):
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
if dirty:
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"root_id": root.id}
|
return {"root_id": root.id}
|
||||||
|
|
||||||
|
|
@ -271,47 +411,12 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
||||||
parent_kind = "region"
|
parent_kind = "region"
|
||||||
|
|
||||||
# Manual 根 (top-level nav): unique L2「根图」only — further creates remount under it.
|
# Manual 根 (top-level nav): unique L2「根图」only — further creates remount under it.
|
||||||
if parent_kind == "region" and str(parent.parent_id or "") == str(root.id):
|
if (
|
||||||
existing_l2 = (
|
parent_kind == "region"
|
||||||
db.query(TopoFolder)
|
and str(parent.parent_id or "") == str(root.id)
|
||||||
.filter(TopoFolder.parent_id == parent.id, TopoFolder.kind == "region")
|
and not is_ume_world_container(parent)
|
||||||
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
):
|
||||||
.all()
|
root_map, _ = _ensure_manual_root_map(db, parent)
|
||||||
)
|
|
||||||
if existing_l2:
|
|
||||||
parent = existing_l2[0]
|
|
||||||
parent_kind = "region"
|
|
||||||
else:
|
|
||||||
# Legacy 根 without 根图 — heal then remount.
|
|
||||||
heal_now = _utcnow()
|
|
||||||
root_map = TopoFolder(
|
|
||||||
id=uuid4().hex,
|
|
||||||
parent_id=parent.id,
|
|
||||||
kind="region",
|
|
||||||
name=MANUAL_ROOT_MAP_NAME,
|
|
||||||
sort_order=0,
|
|
||||||
is_system=True,
|
|
||||||
created_at=heal_now,
|
|
||||||
updated_at=heal_now,
|
|
||||||
)
|
|
||||||
db.add(root_map)
|
|
||||||
db.flush()
|
|
||||||
db.add(
|
|
||||||
TopoView(
|
|
||||||
id=uuid4().hex,
|
|
||||||
folder_id=root_map.id,
|
|
||||||
kind=VIEW_KIND_PHYSICAL,
|
|
||||||
role="core",
|
|
||||||
name=MANUAL_ROOT_MAP_NAME,
|
|
||||||
remark="",
|
|
||||||
sort_order=0,
|
|
||||||
filter={},
|
|
||||||
viewport={},
|
|
||||||
created_at=heal_now,
|
|
||||||
updated_at=heal_now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db.flush()
|
|
||||||
parent = root_map
|
parent = root_map
|
||||||
parent_kind = "region"
|
parent_kind = "region"
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
|
|
@ -390,7 +495,15 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
||||||
|
|
||||||
place_child_region_on_parent_canvas(db, parent, row)
|
place_child_region_on_parent_canvas(db, parent, row)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
try:
|
||||||
reconcile_world_flat_view(db)
|
reconcile_world_flat_view(db)
|
||||||
|
except Exception:
|
||||||
|
# Creating a manual root must not fail because UME world reconcile hiccups.
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger("netx.topology").exception(
|
||||||
|
"reconcile_world_flat_view failed after create_folder id=%s", row.id
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return _folder_out(row)
|
return _folder_out(row)
|
||||||
|
|
@ -598,7 +711,10 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
||||||
# Soft-hide world map on UME World when rule 2A is not met.
|
# Soft-hide world map on UME World when rule 2A is not met.
|
||||||
show_flat = True
|
show_flat = True
|
||||||
if is_ume_world_container(folder):
|
if is_ume_world_container(folder):
|
||||||
|
try:
|
||||||
show_flat = world_map_should_exist(db)
|
show_flat = world_map_should_exist(db)
|
||||||
|
except Exception:
|
||||||
|
show_flat = False
|
||||||
|
|
||||||
# physical first, then custom; stable by sort_order/name.
|
# physical first, then custom; stable by sort_order/name.
|
||||||
ordered = sorted(
|
ordered = sorted(
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,64 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.assertEqual(ne.ip_address, "")
|
self.assertEqual(ne.ip_address, "")
|
||||||
self.assertEqual(ne.device_type, "generic")
|
self.assertEqual(ne.device_type, "generic")
|
||||||
|
|
||||||
|
def test_legacy_single_map_heals_into_root_map(self) -> None:
|
||||||
|
"""Pre-architecture: physical view hung on top-level region (no「根图」)."""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from netx_api.models import TopoFolder, TopoView
|
||||||
|
from netx_api.topology_common import _utcnow
|
||||||
|
from netx_api.topology_membership import VIEW_KIND_PHYSICAL
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
boot = svc.bootstrap_topology_tree(self.db)
|
||||||
|
root = self.db.get(TopoFolder, boot["root_id"])
|
||||||
|
self.assertIsNotNone(root)
|
||||||
|
assert root is not None
|
||||||
|
legacy = TopoFolder(
|
||||||
|
id=uuid4().hex,
|
||||||
|
parent_id=root.id,
|
||||||
|
kind="region",
|
||||||
|
name="旧单图站点",
|
||||||
|
sort_order=0,
|
||||||
|
is_system=False,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
old_view = TopoView(
|
||||||
|
id=uuid4().hex,
|
||||||
|
folder_id=legacy.id,
|
||||||
|
parent_view_id=None,
|
||||||
|
kind=VIEW_KIND_PHYSICAL,
|
||||||
|
role="core",
|
||||||
|
name="拓扑图",
|
||||||
|
remark="",
|
||||||
|
sort_order=0,
|
||||||
|
filter={},
|
||||||
|
viewport={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
self.db.add(legacy)
|
||||||
|
self.db.add(old_view)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
tree = svc.get_topology_tree(self.db)
|
||||||
|
assert tree.root is not None
|
||||||
|
site = next(c for c in tree.root.children if c.id == legacy.id)
|
||||||
|
self.assertEqual(site.views, [])
|
||||||
|
self.assertEqual(len(site.children), 1)
|
||||||
|
root_map = site.children[0]
|
||||||
|
self.assertEqual(root_map.name, "根图")
|
||||||
|
self.assertTrue(any(v.id == old_view.id for v in root_map.views))
|
||||||
|
# Creating another top-level root still works beside the healed legacy site.
|
||||||
|
created = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="新根", kind="region")
|
||||||
|
)
|
||||||
|
tree2 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree2.root is not None
|
||||||
|
self.assertTrue(any(c.id == created.id for c in tree2.root.children))
|
||||||
|
self.assertTrue(any(c.id == legacy.id for c in tree2.root.children))
|
||||||
|
|
||||||
def test_topology_tree_root_region_and_leaf(self) -> None:
|
def test_topology_tree_root_region_and_leaf(self) -> None:
|
||||||
tree = svc.get_topology_tree(self.db)
|
tree = svc.get_topology_tree(self.db)
|
||||||
self.assertIsNotNone(tree.root)
|
self.assertIsNotNone(tree.root)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue