diff --git a/netx_api/topology_common.py b/netx_api/topology_common.py index df8af9f..fcfdb80 100644 --- a/netx_api/topology_common.py +++ b/netx_api/topology_common.py @@ -19,6 +19,14 @@ PHYSICAL_VIEW_NAME = "Physical topology" # Legacy system region name (no longer auto-created; stripped on bootstrap when empty). _LEGACY_UNASSIGNED_NAME = "Unassigned" + +def view_filter_dict(raw: Any) -> dict[str, Any]: + """Coerce TopoView.filter JSON to a dict (bad rows must not 500 the tree).""" + if isinstance(raw, dict): + return dict(raw) + return {} + + PAGE_DEFAULT = 100 PAGE_MAX = 2000 VIEW_GRAPH_NODE_HARD_CAP = 2000 diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 93b9296..097afd9 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -248,12 +248,32 @@ def api_fabric_discover_job( @router.get("/tree") def api_topology_tree(db: Session = Depends(get_db)) -> dict[str, Any]: - return get_topology_tree(db).model_dump() + try: + return get_topology_tree(db).model_dump(mode="json") + except Exception as exc: + import logging + + logging.getLogger("netx.topology").exception("GET /v1/topology/tree failed") + raise HTTPException( + status_code=500, + detail=f"topology_tree_failed:{type(exc).__name__}:{exc}", + ) from exc @router.post("/folders") def api_create_folder(body: TopologyFolderCreate, db: Session = Depends(get_db)) -> dict[str, Any]: - return create_folder(db, body).model_dump() + try: + return create_folder(db, body).model_dump(mode="json") + except HTTPException: + raise + except Exception as exc: + import logging + + logging.getLogger("netx.topology").exception("POST /v1/topology/folders failed") + raise HTTPException( + status_code=500, + detail=f"topology_folder_create_failed:{type(exc).__name__}:{exc}", + ) from exc @router.patch("/folders/{folder_id}") diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 99e8903..8faa831 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -28,6 +28,7 @@ from .topology_common import ( _LEGACY_UNASSIGNED_NAME, _normalize_edge_status, _utcnow, + view_filter_dict, ) # Manual top-level「根」auto-spawns this unique L2 canvas (mirrors UME World / World). @@ -101,7 +102,7 @@ def _view_out(v: TopoView, *, node_count: int = 0) -> TopologyViewOut: kind=normalize_view_kind(getattr(v, "kind", None)), role=normalize_view_role(v.role), sort_order=int(v.sort_order or 0), - filter=dict(v.filter or {}), + filter=view_filter_dict(v.filter), viewport=dict(v.viewport or {}), node_count=node_count, created_at=v.created_at, @@ -357,7 +358,7 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: if not str(v.role or "").strip(): v.role = "core" changed = True - filt = dict(v.filter or {}) + filt = view_filter_dict(v.filter) if "membership" not in filt: v.filter = merge_filter_with_membership( filt, role=normalize_view_role(v.role), kind=kind @@ -548,13 +549,19 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]: """Delete a region and cascade-delete nested regions + maps. - ``force`` is accepted for API compatibility; cascade always runs. System L2「根图」may be removed only as part of cascading from its parent「根」. + UME World container may be deleted explicitly (cascades all synced SBN folders); + a later UME apply / world ensure can recreate it. """ row = _get_folder_or_404(db, folder_id) if str(row.kind or "") == "root": raise HTTPException(status_code=400, detail="cannot_delete_system_folder") - if bool(row.is_system): + + from .ume_topology_world import is_ume_world_container + + # UME World container is system-marked but user-deletable (cascades SBN tree). + # Manual「根图」and other system folders stay protected except via parent cascade. + if bool(row.is_system) and not is_ume_world_container(row): raise HTTPException(status_code=400, detail="cannot_delete_system_folder") _ = force from .topology_region_canvas import remove_region_canvas_placements @@ -583,7 +590,14 @@ def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[s _purge_folder(row) from .ume_topology_world import reconcile_world_flat_view - reconcile_world_flat_view(db) + try: + reconcile_world_flat_view(db) + except Exception: + import logging + + logging.getLogger("netx.topology").exception( + "reconcile_world_flat_view failed after delete_folder id=%s", folder_id + ) db.commit() return {"ok": True, "folder_id": folder_id, "deleted": True} @@ -618,9 +632,9 @@ def get_topology_tree(db: Session) -> TopologyTreeOut: if str(f.kind or "") == "region" ) or any( bool( - dict(v.filter or {}).get("ume_level") - or dict(v.filter or {}).get("world") - or dict(v.filter or {}).get("world_flat") + view_filter_dict(v.filter).get("ume_level") + or view_filter_dict(v.filter).get("world") + or view_filter_dict(v.filter).get("world_flat") ) for v in views ) @@ -669,7 +683,7 @@ def get_topology_tree(db: Session) -> TopologyTreeOut: ne_nc = max(0, raw - int(region_icon_map.get(v.id, 0) or 0)) if ne_nc: return ne_nc - filt = dict(v.filter or {}) + filt = view_filter_dict(v.filter) if filt.get("world_flat") or ( bool(filt.get("world")) and not filt.get("ume_level") ): diff --git a/netx_api/ume_topology_world.py b/netx_api/ume_topology_world.py index bab29b1..9dbe607 100644 --- a/netx_api/ume_topology_world.py +++ b/netx_api/ume_topology_world.py @@ -29,7 +29,9 @@ WORLD_FLAT_FILTER = {"world_flat": True} def _filt(view: TopoView | None) -> dict[str, Any]: if view is None: return {} - return dict(view.filter or {}) + from .topology_common import view_filter_dict + + return view_filter_dict(getattr(view, "filter", None)) def is_ume_level_view(view: TopoView | None) -> bool: diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 6441840..1453d61 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1441,6 +1441,8 @@ const en = { regionDeleted: "Deleted", deleteRegion: "Delete", deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.", + deleteUmeWorldConfirm: + "Delete \"{{name}}\"? This cascades all UME regions and maps under it. A later UME topology apply may recreate it.", renameRegion: "Rename", renameRegionPrompt: "Name", regionRenamed: "Renamed", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3a1c420..dfa293a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1431,6 +1431,8 @@ const zh = { regionDeleted: "已删除", deleteRegion: "删除", deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。", + deleteUmeWorldConfirm: + "删除「{{name}}」?将级联删除其下全部 UME 区域与画布。之后若再跑 UME 拓扑同步/应用,可能重新生成。", renameRegion: "重命名", renameRegionPrompt: "名称", regionRenamed: "已重命名", diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 7d9c8a8..4236538 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -2318,30 +2318,38 @@ export function TopologyPage() { - {!umeNav ? ( + {!umeNav || containerFolder ? (