From 53ddb3b520199d79e409b294998588aa9e121671 Mon Sep 17 00:00:00 2001 From: hansjone Date: Sun, 2 Aug 2026 11:44:35 +0800 Subject: [PATCH] Add fabric-node delete on classify inventory for orphans and placeholders. Allow removing detached or LLDP/WebCRT fabric rows from classify and slices without touching managed/UME inventory; keep UME-only nodes protected. Co-authored-by: Cursor --- netx_api/topology_inventory_lifecycle.py | 129 +++++++++++++++++- netx_api/topology_router.py | 22 +++ netx_api/topology_schemas.py | 11 ++ tests/test_topology.py | 59 ++++++++ web/src/i18n/en.ts | 8 ++ web/src/i18n/zh.ts | 6 + web/src/index.css | 55 ++++++++ .../pages/network/TopologyClassifyPage.tsx | 100 ++++++++++++-- web/src/services/api.ts | 11 ++ web/src/types.ts | 1 + 10 files changed, 391 insertions(+), 11 deletions(-) diff --git a/netx_api/topology_inventory_lifecycle.py b/netx_api/topology_inventory_lifecycle.py index 6320b21..10f8fbb 100644 --- a/netx_api/topology_inventory_lifecycle.py +++ b/netx_api/topology_inventory_lifecycle.py @@ -1,17 +1,35 @@ -"""Detach fabric ↔ inventory links without deleting topology placements/edges.""" +"""Detach fabric ↔ inventory links; purge orphan / non-managed fabric nodes.""" from __future__ import annotations from datetime import datetime from typing import Any +from fastapi import HTTPException from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE -from .models import ManagedNE, TopoFabricNode, TopoView, UmeInventoryNE +from .models import ( + ManagedNE, + TopoFabricEdge, + TopoFabricNode, + TopoView, + TopoViewEdgeStyle, + TopoViewNode, + UmeInventoryNE, +) UME_SYNC_SOURCE = "ume_sync" +# ManagedNE.source values that are not "real" ops inventory (placeholders / sessions). +_NON_INVENTORY_MANAGED_SOURCES = frozenset( + { + LLDP_DISCOVERED_NE_SOURCE, + WEBCRT_NE_SOURCE, + "lldp", + "webcrt", + } +) def _utcnow() -> datetime: @@ -152,6 +170,105 @@ def reconcile_dangling_fabric_links(db: Session) -> dict[str, int]: } +def fabric_node_is_deletable(db: Session, n: TopoFabricNode) -> bool: + """True for orphaned / dangling / LLDP·WebCRT placeholder fabric rows. + + UME-only and real managed inventory (manual / ume_sync) cannot be deleted here. + """ + mid = str(n.managed_ne_id or "").strip() + uid = str(n.ume_ne_id or "").strip() + if not mid: + # Orphaned only — keep UME-linked fabric rows. + return not uid + row = db.get(ManagedNE, mid) + if row is None: + return True + src = str(row.source or "").strip().lower() + return src in _NON_INVENTORY_MANAGED_SOURCES + + +def _strip_fabric_ids_from_membership(db: Session, fabric_ids: set[str]) -> int: + if not fabric_ids: + return 0 + touched = 0 + for view in db.query(TopoView).all(): + filt = dict(view.filter or {}) + mem = filt.get("membership") + if not isinstance(mem, dict): + continue + changed = False + for key in ("seed_fabric_node_ids", "member_fabric_node_ids"): + raw = mem.get(key) + if not isinstance(raw, list): + continue + next_ids = [str(x).strip() for x in raw if str(x).strip() not in fabric_ids] + if next_ids != [str(x).strip() for x in raw if str(x).strip()]: + mem[key] = next_ids + changed = True + if not changed: + continue + filt["membership"] = mem + view.filter = filt + flag_modified(view, "filter") + view.updated_at = _utcnow() + touched += 1 + return touched + + +def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]: + """Hard-delete fabric nodes (placements + edges). Does not touch managed/UME inventory.""" + ids = _norm_ids(fabric_node_ids) + if not ids: + raise HTTPException(status_code=400, detail="fabric_node_ids_required") + rows = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all() + found = {str(r.id): r for r in rows} + missing = [i for i in ids if i not in found] + if missing: + raise HTTPException(status_code=404, detail=f"fabric_node_not_found:{missing[0]}") + blocked = [i for i, r in found.items() if not fabric_node_is_deletable(db, r)] + if blocked: + raise HTTPException( + status_code=400, + detail=f"fabric_node_not_deletable:{blocked[0]}", + ) + + edge_ids = [ + str(e.id) + for e in db.query(TopoFabricEdge) + .filter( + (TopoFabricEdge.a_node_id.in_(ids)) | (TopoFabricEdge.b_node_id.in_(ids)) + ) + .all() + ] + if edge_ids: + db.query(TopoViewEdgeStyle).filter( + TopoViewEdgeStyle.fabric_edge_id.in_(edge_ids) + ).delete(synchronize_session=False) + db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(edge_ids)).delete( + synchronize_session=False + ) + placements = ( + db.query(TopoViewNode) + .filter(TopoViewNode.fabric_node_id.in_(ids)) + .delete(synchronize_session=False) + ) + _strip_fabric_ids_from_membership(db, set(ids)) + for r in rows: + db.delete(r) + db.commit() + try: + from .topology_service import refresh_fabric_stats + + refresh_fabric_stats(db) + except Exception: # noqa: BLE001 + pass + return { + "deleted": len(rows), + "edges_deleted": len(edge_ids), + "placements_deleted": int(placements or 0), + } + + def enrich_fabric_node_dicts( db: Session, items: list[dict[str, Any]] ) -> list[dict[str, Any]]: @@ -190,4 +307,12 @@ def enrich_fabric_node_dicts( item["managed_alive"] = bool(mrow) item["ume_alive"] = bool(uid and uid in ume_alive) item["managed_source"] = managed_source_label(mrow.source) if mrow else "" + if not mid: + item["deletable"] = not bool(uid) + elif mrow is None: + item["deletable"] = True + else: + item["deletable"] = ( + str(mrow.source or "").strip().lower() in _NON_INVENTORY_MANAGED_SOURCES + ) return items diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index efd22ef..0e46619 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -28,6 +28,7 @@ from .topology_schemas import ( FabricDiscoverRequest, FabricManualEdgeIn, FabricNodesBulkTagRequest, + FabricNodesDeleteRequest, FabricNodesMatchRequest, FabricNodeTagPatch, SliceGenerateRequest, @@ -401,6 +402,27 @@ def api_fabric_nodes_bulk_tag( return bulk_tag_fabric_nodes(db, body).model_dump() +@router.delete("/fabric/nodes/{fabric_node_id}") +def api_delete_fabric_node( + fabric_node_id: str, + db: Session = Depends(get_db), +) -> dict[str, Any]: + """Delete a fabric node (orphaned / non-managed only). Does not touch inventory tables.""" + from .topology_inventory_lifecycle import delete_fabric_nodes + + return delete_fabric_nodes(db, [fabric_node_id]) + + +@router.post("/fabric/nodes/delete") +def api_delete_fabric_nodes( + body: FabricNodesDeleteRequest, + db: Session = Depends(get_db), +) -> dict[str, Any]: + from .topology_inventory_lifecycle import delete_fabric_nodes + + return delete_fabric_nodes(db, body.fabric_node_ids) + + @router.post("/slices/generate") def api_generate_slices( body: SliceGenerateRequest, diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 74f3bb3..a564b3a 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -32,6 +32,7 @@ class FabricNodeOut(BaseModel): managed_alive: bool = False ume_alive: bool = False managed_source: str = "" # manual | ume_sync | lldp | webcrt | … + deletable: bool = False class FabricEdgeOut(BaseModel): @@ -425,6 +426,16 @@ class FabricNodesBulkTagOut(BaseModel): samples: list[dict[str, Any]] = Field(default_factory=list) +class FabricNodesDeleteRequest(BaseModel): + fabric_node_ids: list[str] = Field(default_factory=list, min_length=1) + + +class FabricNodesDeleteOut(BaseModel): + deleted: int = 0 + edges_deleted: int = 0 + placements_deleted: int = 0 + + class SliceGenerateRequest(BaseModel): folder_id: str template: str = Field(description="core_only | core_agg | agg_access") diff --git a/tests/test_topology.py b/tests/test_topology.py index 775dfb8..96b62eb 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1203,6 +1203,65 @@ Management Addresses: self.db.refresh(dangling) self.assertFalse(str(dangling.managed_ne_id or "").strip()) + def test_delete_fabric_node_only_orphans_and_placeholders(self) -> None: + from fastapi import HTTPException + + from netx_api.topology_inventory_lifecycle import delete_fabric_nodes + + suffix = uuid4().hex[:8] + real = ManagedNE( + id=f"real-{suffix}", + name=f"REAL-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.77.{(int(suffix[:2], 16) % 200) + 1}.1", + source="", + ) + ph = ManagedNE( + id=f"ph-{suffix}", + name=f"PH-{suffix}", + vendor="Other", + device_type="generic", + ip_address="", + source=LLDP_DISCOVERED_NE_SOURCE, + ) + self.db.add(real) + self.db.add(ph) + self.db.commit() + fab_real = svc.ensure_fabric_node_for_managed(self.db, real) + fab_ph = svc.ensure_fabric_node_for_managed(self.db, ph) + orphan = TopoFabricNode( + id=f"orp-{suffix}", + name=f"ORP-{suffix}", + ip="10.77.0.9", + ) + ume_only = TopoFabricNode( + id=f"ume-{suffix}", + name=f"UME-{suffix}", + ip="10.77.0.8", + ume_ne_id=f"ume-ne-{suffix}", + ) + self.db.add(orphan) + self.db.add(ume_only) + self.db.commit() + + with self.assertRaises(HTTPException) as ctx: + delete_fabric_nodes(self.db, [fab_real.id]) + self.assertEqual(ctx.exception.status_code, 400) + + with self.assertRaises(HTTPException) as ctx_ume: + delete_fabric_nodes(self.db, [ume_only.id]) + self.assertEqual(ctx_ume.exception.status_code, 400) + + out = delete_fabric_nodes(self.db, [fab_ph.id, orphan.id]) + self.assertEqual(out["deleted"], 2) + self.assertIsNone(self.db.get(TopoFabricNode, fab_ph.id)) + self.assertIsNone(self.db.get(TopoFabricNode, orphan.id)) + self.assertIsNotNone(self.db.get(TopoFabricNode, fab_real.id)) + self.assertIsNotNone(self.db.get(TopoFabricNode, ume_only.id)) + self.assertIsNotNone(self.db.get(ManagedNE, real.id)) + self.assertIsNotNone(self.db.get(ManagedNE, ph.id)) + if __name__ == "__main__": unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ee45b13..604a2f4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1424,6 +1424,14 @@ const en = { edit: "Edit", save: "Save", cancel: "Cancel", + delete: "Delete", + deleteSelected: "Delete nodes ({{count}})", + deleteConfirm: + "Delete {{count}} fabric node(s) (placements and edges)? Managed/UME inventory is not touched.", + deleteOk: "Deleted {{count}} node(s)", + deleteHint: + "Only orphaned or LLDP/WebCRT placeholder fabric nodes can be deleted; UME-only and real managed NEs cannot", + deleteBlocked: "UME-only or still linked to real managed inventory — cannot delete here", rowSaved: "Saved", loading: "Loading…", empty: "No NEs.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 263088b..b7716ba 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1420,6 +1420,12 @@ const zh = { edit: "编辑", save: "保存", cancel: "取消", + delete: "删除", + deleteSelected: "删除节点 ({{count}})", + deleteConfirm: "将从拓扑 Fabric 删除 {{count}} 个节点(含上图与边),不影响网元管理/UME 清单。确定?", + deleteOk: "已删除 {{count}} 个节点", + deleteHint: "仅可删除已解绑节点,或 LLDP/WebCRT 等非实际管理占位;仅 UME / 实际管理网元不可删", + deleteBlocked: "仅 UME 或仍关联实际管理网元,不可在此删除", rowSaved: "已保存", loading: "加载中…", empty: "没有网元。", diff --git a/web/src/index.css b/web/src/index.css index 9b6fd01..98e9524 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4160,6 +4160,61 @@ pre { display: inline-flex; gap: 4px; } +.topo-classify__help { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid #94a3b8; + color: #64748b; + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: help; + user-select: none; + flex: 0 0 auto; +} +.topo-classify__help:hover, +.topo-classify__help:focus-visible { + border-color: #64748b; + color: #334155; + outline: none; +} +.topo-classify__help-tip { + display: none; + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 40; + width: max-content; + max-width: min(280px, 70vw); + padding: 8px 10px; + border-radius: 6px; + background: #0f172a; + color: #f8fafc; + font-size: 12px; + font-weight: 400; + line-height: 1.45; + white-space: normal; + text-align: left; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.22); + pointer-events: none; +} +.topo-classify__help-tip::after { + content: ""; + position: absolute; + bottom: 100%; + left: 8px; + border: 5px solid transparent; + border-bottom-color: #0f172a; +} +.topo-classify__help:hover .topo-classify__help-tip, +.topo-classify__help:focus-visible .topo-classify__help-tip { + display: block; +} .topo-classify__section h3 { margin: 0 0 10px; font-size: 14px; diff --git a/web/src/pages/network/TopologyClassifyPage.tsx b/web/src/pages/network/TopologyClassifyPage.tsx index a2a51e6..15191d3 100644 --- a/web/src/pages/network/TopologyClassifyPage.tsx +++ b/web/src/pages/network/TopologyClassifyPage.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { bulkTagFabricNodes, + deleteFabricNode, + deleteFabricNodes, fetchFabricNodes, fetchTopologyTree, generateTopologySlices, @@ -21,6 +23,20 @@ function flattenRegions(root: TopologyTreeFolderItem | null | undefined): Topolo return (root.children || []).filter((c) => String(c.kind) === "region"); } +/** Prefer API flag; fall back so UI still works if backend is stale. */ +function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean { + if (typeof n.deletable === "boolean") return n.deletable; + const status = String(n.link_status || "").toLowerCase(); + if (status === "ume" || status === "both") return false; + if (status === "orphaned") return true; + if (!String(n.managed_ne_id || "").trim()) { + return !String(n.ume_ne_id || "").trim(); + } + if (n.managed_alive === false) return true; + const src = String(n.managed_source || "").toLowerCase(); + return src === "lldp" || src === "webcrt"; +} + export function TopologyClassifyPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); @@ -156,6 +172,26 @@ export function TopologyClassifyPage() { onError: (err) => showError(String(err)), }); + const deleteOneMut = useMutation({ + mutationFn: (id: string) => deleteFabricNode(id), + onSuccess: async (out) => { + showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted))); + setSelected({}); + await invalidateList(); + }, + onError: (err) => showError(String(err)), + }); + + const deleteBulkMut = useMutation({ + mutationFn: (ids: string[]) => deleteFabricNodes(ids), + onSuccess: async (out) => { + showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted))); + setSelected({}); + await invalidateList(); + }, + onError: (err) => showError(String(err)), + }); + const slicePreviewMut = useMutation({ mutationFn: () => generateTopologySlices({ @@ -187,6 +223,9 @@ export function TopologyClassifyPage() { }); const items = listQuery.data?.items || []; + const deletableSelected = items + .filter((n) => selected[n.id] && isFabricNodeDeletable(n)) + .map((n) => n.id); const total = listQuery.data?.total || 0; const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const selectedCount = Object.values(selected).filter(Boolean).length; @@ -373,8 +412,29 @@ export function TopologyClassifyPage() { > {t("topoClassify.confirmAssign")} - - {t("topoClassify.selectedCount").replace("{{count}}", String(selectedCount))} + + + ? + + {t("topoClassify.deleteHint")} + @@ -475,13 +535,35 @@ export function TopologyClassifyPage() { ) : ( - +
+ + +
)} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index e00eede..2470de9 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1040,6 +1040,17 @@ export const patchFabricNodeTags = ( body, ); +export const deleteFabricNode = (fabricNodeId: string) => + apiDelete<{ deleted: number; edges_deleted: number; placements_deleted: number }>( + `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}`, + ); + +export const deleteFabricNodes = (fabricNodeIds: string[]) => + apiPost<{ deleted: number; edges_deleted: number; placements_deleted: number }>( + "/v1/topology/fabric/nodes/delete", + { fabric_node_ids: fabricNodeIds }, + ); + export const generateTopologySlices = (body: { folder_id: string; template: "core_only" | "core_agg" | "agg_access"; diff --git a/web/src/types.ts b/web/src/types.ts index 974f681..259fc2c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -445,6 +445,7 @@ export type FabricNodeSearchHit = { managed_alive?: boolean; ume_alive?: boolean; managed_source?: string; + deletable?: boolean; views?: Array<{ view_id: string; view_name: string;