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 <cursoragent@cursor.com>
This commit is contained in:
hansjone 2026-08-02 11:44:35 +08:00
parent a5a3a4f156
commit 53ddb3b520
10 changed files with 391 additions and 11 deletions

View file

@ -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 __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE 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" 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: 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( def enrich_fabric_node_dicts(
db: Session, items: list[dict[str, Any]] db: Session, items: list[dict[str, Any]]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -190,4 +307,12 @@ def enrich_fabric_node_dicts(
item["managed_alive"] = bool(mrow) item["managed_alive"] = bool(mrow)
item["ume_alive"] = bool(uid and uid in ume_alive) item["ume_alive"] = bool(uid and uid in ume_alive)
item["managed_source"] = managed_source_label(mrow.source) if mrow else "" 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 return items

View file

@ -28,6 +28,7 @@ from .topology_schemas import (
FabricDiscoverRequest, FabricDiscoverRequest,
FabricManualEdgeIn, FabricManualEdgeIn,
FabricNodesBulkTagRequest, FabricNodesBulkTagRequest,
FabricNodesDeleteRequest,
FabricNodesMatchRequest, FabricNodesMatchRequest,
FabricNodeTagPatch, FabricNodeTagPatch,
SliceGenerateRequest, SliceGenerateRequest,
@ -401,6 +402,27 @@ def api_fabric_nodes_bulk_tag(
return bulk_tag_fabric_nodes(db, body).model_dump() 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") @router.post("/slices/generate")
def api_generate_slices( def api_generate_slices(
body: SliceGenerateRequest, body: SliceGenerateRequest,

View file

@ -32,6 +32,7 @@ class FabricNodeOut(BaseModel):
managed_alive: bool = False managed_alive: bool = False
ume_alive: bool = False ume_alive: bool = False
managed_source: str = "" # manual | ume_sync | lldp | webcrt | … managed_source: str = "" # manual | ume_sync | lldp | webcrt | …
deletable: bool = False
class FabricEdgeOut(BaseModel): class FabricEdgeOut(BaseModel):
@ -425,6 +426,16 @@ class FabricNodesBulkTagOut(BaseModel):
samples: list[dict[str, Any]] = Field(default_factory=list) 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): class SliceGenerateRequest(BaseModel):
folder_id: str folder_id: str
template: str = Field(description="core_only | core_agg | agg_access") template: str = Field(description="core_only | core_agg | agg_access")

View file

@ -1203,6 +1203,65 @@ Management Addresses:
self.db.refresh(dangling) self.db.refresh(dangling)
self.assertFalse(str(dangling.managed_ne_id or "").strip()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1424,6 +1424,14 @@ const en = {
edit: "Edit", edit: "Edit",
save: "Save", save: "Save",
cancel: "Cancel", 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", rowSaved: "Saved",
loading: "Loading…", loading: "Loading…",
empty: "No NEs.", empty: "No NEs.",

View file

@ -1420,6 +1420,12 @@ const zh = {
edit: "编辑", edit: "编辑",
save: "保存", save: "保存",
cancel: "取消", cancel: "取消",
delete: "删除",
deleteSelected: "删除节点 ({{count}})",
deleteConfirm: "将从拓扑 Fabric 删除 {{count}} 个节点(含上图与边),不影响网元管理/UME 清单。确定?",
deleteOk: "已删除 {{count}} 个节点",
deleteHint: "仅可删除已解绑节点,或 LLDP/WebCRT 等非实际管理占位;仅 UME / 实际管理网元不可删",
deleteBlocked: "仅 UME 或仍关联实际管理网元,不可在此删除",
rowSaved: "已保存", rowSaved: "已保存",
loading: "加载中…", loading: "加载中…",
empty: "没有网元。", empty: "没有网元。",

View file

@ -4160,6 +4160,61 @@ pre {
display: inline-flex; display: inline-flex;
gap: 4px; 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 { .topo-classify__section h3 {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 14px; font-size: 14px;

View file

@ -2,6 +2,8 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
bulkTagFabricNodes, bulkTagFabricNodes,
deleteFabricNode,
deleteFabricNodes,
fetchFabricNodes, fetchFabricNodes,
fetchTopologyTree, fetchTopologyTree,
generateTopologySlices, generateTopologySlices,
@ -21,6 +23,20 @@ function flattenRegions(root: TopologyTreeFolderItem | null | undefined): Topolo
return (root.children || []).filter((c) => String(c.kind) === "region"); 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() { export function TopologyClassifyPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
@ -156,6 +172,26 @@ export function TopologyClassifyPage() {
onError: (err) => showError(String(err)), 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({ const slicePreviewMut = useMutation({
mutationFn: () => mutationFn: () =>
generateTopologySlices({ generateTopologySlices({
@ -187,6 +223,9 @@ export function TopologyClassifyPage() {
}); });
const items = listQuery.data?.items || []; 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 total = listQuery.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const selectedCount = Object.values(selected).filter(Boolean).length; const selectedCount = Object.values(selected).filter(Boolean).length;
@ -373,8 +412,29 @@ export function TopologyClassifyPage() {
> >
{t("topoClassify.confirmAssign")} {t("topoClassify.confirmAssign")}
</button> </button>
<span className="panel__hint"> <button
{t("topoClassify.selectedCount").replace("{{count}}", String(selectedCount))} type="button"
className="btn btn--sm btn--danger"
disabled={deleteBulkMut.isPending || deletableSelected.length === 0}
title={t("topoClassify.deleteHint")}
onClick={() => {
const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}",
String(deletableSelected.length),
);
if (window.confirm(msg)) deleteBulkMut.mutate(deletableSelected);
}}
>
{t("topoClassify.deleteSelected").replace(
"{{count}}",
String(deletableSelected.length),
)}
</button>
<span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}>
?
<span className="topo-classify__help-tip" role="tooltip">
{t("topoClassify.deleteHint")}
</span>
</span> </span>
</div> </div>
@ -475,13 +535,35 @@ export function TopologyClassifyPage() {
</button> </button>
</div> </div>
) : ( ) : (
<button <div className="topo-classify__row-actions">
type="button" <button
className="btn btn--sm btn--ghost" type="button"
onClick={() => startEdit(n)} className="btn btn--sm btn--ghost"
> onClick={() => startEdit(n)}
{t("topoClassify.edit")} >
</button> {t("topoClassify.edit")}
</button>
<button
type="button"
className="btn btn--sm btn--danger"
disabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
title={
isFabricNodeDeletable(n)
? t("topoClassify.deleteHint")
: t("topoClassify.deleteBlocked")
}
onClick={() => {
if (!isFabricNodeDeletable(n)) return;
const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}",
"1",
);
if (window.confirm(msg)) deleteOneMut.mutate(n.id);
}}
>
{t("topoClassify.delete")}
</button>
</div>
)} )}
</td> </td>
</tr> </tr>

View file

@ -1040,6 +1040,17 @@ export const patchFabricNodeTags = (
body, 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: { export const generateTopologySlices = (body: {
folder_id: string; folder_id: string;
template: "core_only" | "core_agg" | "agg_access"; template: "core_only" | "core_agg" | "agg_access";

View file

@ -445,6 +445,7 @@ export type FabricNodeSearchHit = {
managed_alive?: boolean; managed_alive?: boolean;
ume_alive?: boolean; ume_alive?: boolean;
managed_source?: string; managed_source?: string;
deletable?: boolean;
views?: Array<{ views?: Array<{
view_id: string; view_id: string;
view_name: string; view_name: string;