mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 00:23:19 +08:00
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:
parent
35a3980dc0
commit
961d523479
7 changed files with 89 additions and 30 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1431,6 +1431,8 @@ const zh = {
|
||||||
regionDeleted: "已删除",
|
regionDeleted: "已删除",
|
||||||
deleteRegion: "删除",
|
deleteRegion: "删除",
|
||||||
deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。",
|
deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。",
|
||||||
|
deleteUmeWorldConfirm:
|
||||||
|
"删除「{{name}}」?将级联删除其下全部 UME 区域与画布。之后若再跑 UME 拓扑同步/应用,可能重新生成。",
|
||||||
renameRegion: "重命名",
|
renameRegion: "重命名",
|
||||||
renameRegionPrompt: "名称",
|
renameRegionPrompt: "名称",
|
||||||
regionRenamed: "已重命名",
|
regionRenamed: "已重命名",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue