Harden topology tree against bad view filters and allow deleting UME World.

Coerce non-dict filter JSON so /tree no longer 500s, surface API failure details, and let users remove the UME World container for local resets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 09:58:20 +08:00
parent 35a3980dc0
commit 961d523479
7 changed files with 89 additions and 30 deletions

View file

@ -19,6 +19,14 @@ PHYSICAL_VIEW_NAME = "Physical topology"
# Legacy system region name (no longer auto-created; stripped on bootstrap when empty). # Legacy system region name (no longer auto-created; stripped on bootstrap when empty).
_LEGACY_UNASSIGNED_NAME = "Unassigned" _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_DEFAULT = 100
PAGE_MAX = 2000 PAGE_MAX = 2000
VIEW_GRAPH_NODE_HARD_CAP = 2000 VIEW_GRAPH_NODE_HARD_CAP = 2000

View file

@ -248,12 +248,32 @@ def api_fabric_discover_job(
@router.get("/tree") @router.get("/tree")
def api_topology_tree(db: Session = Depends(get_db)) -> dict[str, Any]: 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") @router.post("/folders")
def api_create_folder(body: TopologyFolderCreate, db: Session = Depends(get_db)) -> dict[str, Any]: 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}") @router.patch("/folders/{folder_id}")

View file

@ -28,6 +28,7 @@ from .topology_common import (
_LEGACY_UNASSIGNED_NAME, _LEGACY_UNASSIGNED_NAME,
_normalize_edge_status, _normalize_edge_status,
_utcnow, _utcnow,
view_filter_dict,
) )
# Manual top-level「根」auto-spawns this unique L2 canvas (mirrors UME World / World). # 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)), kind=normalize_view_kind(getattr(v, "kind", None)),
role=normalize_view_role(v.role), role=normalize_view_role(v.role),
sort_order=int(v.sort_order or 0), sort_order=int(v.sort_order or 0),
filter=dict(v.filter or {}), filter=view_filter_dict(v.filter),
viewport=dict(v.viewport or {}), viewport=dict(v.viewport or {}),
node_count=node_count, node_count=node_count,
created_at=v.created_at, 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(): if not str(v.role or "").strip():
v.role = "core" v.role = "core"
changed = True changed = True
filt = dict(v.filter or {}) filt = view_filter_dict(v.filter)
if "membership" not in filt: if "membership" not in filt:
v.filter = merge_filter_with_membership( v.filter = merge_filter_with_membership(
filt, role=normalize_view_role(v.role), kind=kind 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]: def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
"""Delete a region and cascade-delete nested regions + maps. """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「根」. 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) row = _get_folder_or_404(db, folder_id)
if str(row.kind or "") == "root": if str(row.kind or "") == "root":
raise HTTPException(status_code=400, detail="cannot_delete_system_folder") 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") raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
_ = force _ = force
from .topology_region_canvas import remove_region_canvas_placements 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) _purge_folder(row)
from .ume_topology_world import reconcile_world_flat_view 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() db.commit()
return {"ok": True, "folder_id": folder_id, "deleted": True} 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" if str(f.kind or "") == "region"
) or any( ) or any(
bool( bool(
dict(v.filter or {}).get("ume_level") view_filter_dict(v.filter).get("ume_level")
or dict(v.filter or {}).get("world") or view_filter_dict(v.filter).get("world")
or dict(v.filter or {}).get("world_flat") or view_filter_dict(v.filter).get("world_flat")
) )
for v in views 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)) ne_nc = max(0, raw - int(region_icon_map.get(v.id, 0) or 0))
if ne_nc: if ne_nc:
return ne_nc return ne_nc
filt = dict(v.filter or {}) filt = view_filter_dict(v.filter)
if filt.get("world_flat") or ( if filt.get("world_flat") or (
bool(filt.get("world")) and not filt.get("ume_level") bool(filt.get("world")) and not filt.get("ume_level")
): ):

View file

@ -29,7 +29,9 @@ WORLD_FLAT_FILTER = {"world_flat": True}
def _filt(view: TopoView | None) -> dict[str, Any]: def _filt(view: TopoView | None) -> dict[str, Any]:
if view is None: if view is None:
return {} 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: def is_ume_level_view(view: TopoView | None) -> bool:

View file

@ -1441,6 +1441,8 @@ const en = {
regionDeleted: "Deleted", regionDeleted: "Deleted",
deleteRegion: "Delete", deleteRegion: "Delete",
deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.", 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", renameRegion: "Rename",
renameRegionPrompt: "Name", renameRegionPrompt: "Name",
regionRenamed: "Renamed", regionRenamed: "Renamed",

View file

@ -1431,6 +1431,8 @@ const zh = {
regionDeleted: "已删除", regionDeleted: "已删除",
deleteRegion: "删除", deleteRegion: "删除",
deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。", deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。",
deleteUmeWorldConfirm:
"删除「{{name}}」?将级联删除其下全部 UME 区域与画布。之后若再跑 UME 拓扑同步/应用,可能重新生成。",
renameRegion: "重命名", renameRegion: "重命名",
renameRegionPrompt: "名称", renameRegionPrompt: "名称",
regionRenamed: "已重命名", regionRenamed: "已重命名",

View file

@ -2318,30 +2318,38 @@ export function TopologyPage() {
</span> </span>
</span> </span>
</button> </button>
{!umeNav ? ( {!umeNav || containerFolder ? (
<div className="topo-map-list__actions"> <div className="topo-map-list__actions">
<button {!containerFolder ? (
type="button" <button
className="topo-map-list__icon" type="button"
title={t("topology.renameRegion")} className="topo-map-list__icon"
aria-label={t("topology.renameRegion")} title={t("topology.renameRegion")}
disabled={!!folder.is_system || renameRegionMut.isPending} aria-label={t("topology.renameRegion")}
onClick={() => promptRenameRegion(folder.id, folder.name)} disabled={!!folder.is_system || renameRegionMut.isPending}
> onClick={() => promptRenameRegion(folder.id, folder.name)}
<PencilIcon /> >
</button> <PencilIcon />
</button>
) : null}
<button <button
type="button" type="button"
className="topo-map-list__icon" className="topo-map-list__icon"
title={t("topology.deleteRegion")} title={t("topology.deleteRegion")}
aria-label={t("topology.deleteRegion")} aria-label={t("topology.deleteRegion")}
disabled={!!folder.is_system} disabled={
deleteFolderMut.isPending ||
(!!folder.is_system && !containerFolder)
}
onClick={() => { onClick={() => {
const msg = t("topology.deleteRegionConfirm").replace( const msg = (
"{{name}}", containerFolder
folder.name, ? t("topology.deleteUmeWorldConfirm")
); : t("topology.deleteRegionConfirm")
if (window.confirm(msg)) deleteFolderMut.mutate(folder.id); ).replace("{{name}}", folder.name);
if (window.confirm(msg)) {
deleteFolderMut.mutate(folder.id);
}
}} }}
> >
<CloseIcon /> <CloseIcon />
@ -4116,7 +4124,10 @@ export function TopologyPage() {
</> </>
) : treeFailed ? ( ) : treeFailed ? (
<> <>
{t("topology.treeLoadFailed")}{" "} {t("topology.treeLoadFailed")}
{treeQuery.error ? (
<span className="muted"> ({String(treeQuery.error)})</span>
) : null}{" "}
<button <button
type="button" type="button"
className="btn btn--sm btn--ghost" className="btn btn--sm btn--ghost"