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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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