Defer canvas edge deletes to Save and cascade orphan Fabric on NE detach.

Queue fabric edge deletes until Save with undo-safe pending state; hard-delete topology/LLDP placeholders from the canvas; purge fully orphaned fabric nodes after inventory detach, with a periodic reconcile GC.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 22:43:03 +08:00
parent 2aafb72676
commit 6e2c8517cf
16 changed files with 732 additions and 166 deletions

View file

@ -126,7 +126,8 @@ src/
## 拓扑管理(Fabric + 站点目录,对齐厂商)
- 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。
- 删除运维网元:解绑 `fabric.managed_ne_id`(及 view membership),**不删**上图点/边;UME 库存 reconcile 对称解绑 `ume_ne_id`。历史悬空引用:`POST /fabric/reconcile-links`(亦并入 `cleanup-duplicates`)。
- 删除运维网元(手工 / WebCRT / `ume_sync` ManagedNE 同一路径):先解绑 `fabric.managed_ne_id`(及 view membership)。若该 Fabric 节点同时仍绑 UME → 变为 UME-only,**保留**节点与直连边;若解绑后已无 managed 且无 UME(完全孤儿)→ **硬删** Fabric 节点并级联直连边 / placements。UME 库存 reconcile 对称解绑 `ume_ne_id`,同样在完全孤儿时 purge。历史悬空引用与孤儿 GC:`POST /fabric/reconcile-links`(`cleanup-duplicates` 先 merge/absorb 再扫孤儿);后台默认每 6h 跑一次(`fabric_reconcile_scheduler_*`,可关)。
- 画布:移出本图仅影响 placement(保存落库);删边本地排队、**保存**才删 Fabric(Undo 仅保存前有效);topology/lldp 占位可右键硬删(ManagedNE+Fabric+边)。未匹配且未建占位的对端不上 Fabric 边。
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node`

View file

@ -718,10 +718,12 @@ const en = {
edit: "Edit",
delete: "Delete",
openTerminal: "Terminal",
confirmDelete: "Delete this network element?",
confirmDelete:
"Delete this network element? If its Fabric node is managed-only (no UME binding), that node and its direct links will also be removed.",
batchDelete: "Batch delete",
batchDeleting: "Deleting…",
batchDeleteConfirm: "Delete {{n}} selected network element(s)? This cannot be undone.",
batchDeleteConfirm:
"Delete {{n}} selected network element(s)? Cannot be undone. Managed-only Fabric nodes cascade (node + direct links); nodes still bound to UME keep topology.",
batchDeleteDone: "Deleted {{n}} network element(s)",
batchDeleteSelectRequired: "Select network elements to delete",
keywordPh: "name / IP / user / tags",
@ -784,7 +786,8 @@ const en = {
syncing: "Syncing…",
delete: "Delete UME NEs",
deleting: "Deleting…",
deleteConfirm: "Delete all managed NEs created from UME sync? This cannot be undone.",
deleteConfirm:
"Delete all managed NEs created from UME sync? Cannot be undone. Fabric nodes that become fully orphaned (no UME left) are removed with their direct links.",
done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total",
deletedDone: "Deleted {{n}} UME-synced NEs",
},
@ -1409,8 +1412,8 @@ const en = {
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
localBrowserOnly: "this browser only",
deleteEdgeConfirm:
"Delete selected link(s)? This removes them from Fabric globally; LLDP may recreate them on the next collect.",
edgeDeleted: "Deleted {{count}} link(s)",
"Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.",
edgeDeleted: "Queued {{count}} link delete(s) — Save to apply",
discoverCancelled: "Discovery cancelled",
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.",
truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze in classify/slices or create a new map.",
@ -1418,8 +1421,8 @@ const en = {
truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
deleteSelectionConfirm:
"Remove {{nodes}} NE(s) from this map and delete {{edges}} Fabric link(s)?",
selectionDeleted: "Removed {{nodes}} NE(s) and deleted {{edges}} link(s)",
"Remove {{nodes}} NE(s) from this map and {{edges}} link(s)? Link deletes apply on Save; NE remove from map applies on Save.",
selectionDeleted: "Queued: remove {{nodes}} NE(s), delete {{edges}} link(s) — Save to apply",
newMap: "New",
newMapName: "New topology",
rename: "Rename",
@ -1498,6 +1501,10 @@ const en = {
openWebcrt: "Open terminal",
openNe: "NE details",
removeNode: "Remove from canvas",
deletePlaceholder: "Delete placeholder",
deletePlaceholderConfirm:
"Permanently delete this placeholder NE (inventory + Fabric links)? This cannot be undone with canvas Undo.",
deletePlaceholderDone: "Placeholder deleted",
removeEdge: "Delete edge",
nodeMenu: "Node",
paneMenu: "Canvas",
@ -1610,8 +1617,9 @@ const en = {
locateOnCanvas: "Click to locate on canvas",
openPortTraffic: "Open port traffic",
removeStale: "Clear missing ({{count}})",
removeStaleHint: "Manually delete red missing edges; scheduled collect also purges after 4 consecutive misses",
staleRemoved: "Removed {{count}} missing edge(s)",
removeStaleHint:
"Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses",
staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply",
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
},
topoClassify: {

View file

@ -712,10 +712,12 @@ const zh = {
edit: "编辑",
delete: "删除",
openTerminal: "终端",
confirmDelete: "确定删除该网元?",
confirmDelete:
"确定删除该网元?若拓扑中仅绑定此运维网元(无 UME),将同时删除对应 Fabric 节点及其直连链路。",
batchDelete: "批量删除",
batchDeleting: "删除中…",
batchDeleteConfirm: "确定删除选中的 {{n}} 台网元?此操作不可恢复。",
batchDeleteConfirm:
"确定删除选中的 {{n}} 台网元?不可恢复。仅绑定运维侧的节点会级联清除 Fabric 节点与直连链路;仍绑 UME 的会保留拓扑。",
batchDeleteDone: "已删除 {{n}} 台网元",
batchDeleteSelectRequired: "请先勾选要删除的网元",
keywordPh: "名称 / IP / 用户名 / 标签",
@ -777,7 +779,8 @@ const zh = {
syncing: "同步中…",
delete: "删除 UME 网元",
deleting: "删除中…",
deleteConfirm: "确定删除所有通过 UME 同步到网元管理中的网元?此操作不可恢复。",
deleteConfirm:
"确定删除所有通过 UME 同步到网元管理中的网元?不可恢复。仅绑定这些 ManagedNE、且无 UME 投影的 Fabric 节点会连同直连链路一并清除。",
done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台",
deletedDone: "已删除 {{n}} 台 UME 同步网元",
},
@ -1400,16 +1403,17 @@ const zh = {
projectNeighbors: "投影邻居上图",
projectedNeighbors: "已投影 {{count}} 个邻居上图",
localBrowserOnly: "仅本浏览器",
deleteEdgeConfirm: "删除选中链路?将从 Fabric 全局移除;LLDP 下次采集若仍存在可能再次出现。",
edgeDeleted: "已删除 {{count}} 条链路",
deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。",
edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)",
discoverCancelled: "发现已取消",
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。",
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
deleteSelectionConfirm: "删除选中的 {{nodes}} 个网元(移出画布)和 {{edges}} 条链路(从 Fabric 删除)?",
selectionDeleted: "已移除 {{nodes}} 个网元、删除 {{edges}} 条链路",
deleteSelectionConfirm:
"从本图移除 {{nodes}} 个网元、删除 {{edges}} 条链路?移出与链路删除均在点击「保存」后生效。",
selectionDeleted: "已标记:移除 {{nodes}} 个网元、删除 {{edges}} 条链路(保存后生效)",
newMap: "新建",
newMapName: "新拓扑图",
rename: "重命名",
@ -1488,6 +1492,9 @@ const zh = {
openWebcrt: "打开终端",
openNe: "网元详情",
removeNode: "从画布移除",
deletePlaceholder: "删除占位网元",
deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。",
deletePlaceholderDone: "占位网元已删除",
removeEdge: "删除链路",
nodeMenu: "网元",
paneMenu: "画布",
@ -1598,8 +1605,8 @@ const zh = {
locateOnCanvas: "点击定位到画布",
openPortTraffic: "打开端口流量",
removeStale: "清除未发现 ({{count}})",
removeStaleHint: "手动删除红色未发现链路;周期采集连续 4 次未发现也会自动清理",
staleRemoved: "已删除 {{count}} 条未发现链路",
removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理",
staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)",
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
},
topoClassify: {

View file

@ -39,6 +39,7 @@ import {
createTopologyPlaceholder,
createTopologyView,
deleteFabricEdges,
purgePlaceholderFabricNodes,
deleteTopologyFolder,
deleteTopologyMap,
fetchLldpCollectDashboard,
@ -278,7 +279,11 @@ type NeNodeData = {
node_count?: number;
};
type HistorySnap = { nodes: Node<NeNodeData>[]; edges: Edge[] };
type HistorySnap = {
nodes: Node<NeNodeData>[];
edges: Edge[];
pendingEdgeDeletes: string[];
};
type PaletteSource = "managed" | "ume";
@ -921,6 +926,8 @@ export function TopologyPage() {
const historyRef = useRef<HistorySnap[]>([]);
const redoRef = useRef<HistorySnap[]>([]);
const historyLockRef = useRef(false);
/** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
const connectClickRef = useRef<string | null>(null);
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
@ -1259,6 +1266,7 @@ export function TopologyPage() {
appliedMapIdRef.current = mapId;
historyRef.current = [];
redoRef.current = [];
pendingEdgeDeletesRef.current = new Set();
clearDirty();
bumpHistory();
needsInitialFitRef.current = true;
@ -1299,6 +1307,7 @@ export function TopologyPage() {
{
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
},
];
redoRef.current = [];
@ -1311,10 +1320,12 @@ export function TopologyPage() {
redoRef.current.push({
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
});
historyLockRef.current = true;
setNodes(prev.nodes);
setEdges(prev.edges);
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
markDirty();
bumpHistory();
historyLockRef.current = false;
@ -1326,10 +1337,12 @@ export function TopologyPage() {
historyRef.current.push({
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
});
historyLockRef.current = true;
setNodes(next.nodes);
setEdges(next.edges);
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
markDirty();
bumpHistory();
historyLockRef.current = false;
@ -1353,7 +1366,9 @@ export function TopologyPage() {
if (opts?.persist && mapId) {
try {
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
clearDirty();
if (pendingEdgeDeletesRef.current.size === 0) {
clearDirty();
}
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
} catch (err) {
showError(String(err));
@ -1501,6 +1516,11 @@ export function TopologyPage() {
const saveMut = useMutation({
mutationFn: async () => {
if (!mapId) throw new Error(t("topology.selectMap"));
const pendingEdges = [...pendingEdgeDeletesRef.current];
if (pendingEdges.length) {
await deleteFabricEdges(pendingEdges);
pendingEdgeDeletesRef.current.clear();
}
// Positions patch only updates listed nodes; sync canvas removals first.
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
@ -1553,6 +1573,19 @@ export function TopologyPage() {
});
try {
if (dirtyRef.current) {
const pendingEdges = [...pendingEdgeDeletesRef.current];
if (pendingEdges.length) {
await deleteFabricEdges(pendingEdges);
pendingEdgeDeletesRef.current.clear();
}
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = new Set(nodes.map((n) => n.id));
const toRemove = serverIds.filter((id) => !localIds.has(id));
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
@ -1697,7 +1730,7 @@ export function TopologyPage() {
setDiscovering(false);
}
},
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, scheduleFitView],
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, scheduleFitView, graphQuery.data],
);
const cancelDiscover = useCallback(async () => {
@ -1777,6 +1810,7 @@ export function TopologyPage() {
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
pendingEdgeDeletesRef.current = new Set();
clearDirty();
} catch (err) {
showError(String(err));
@ -2029,41 +2063,31 @@ export function TopologyPage() {
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
}, [setNodes, setEdges]);
const persistDeleteEdges = useCallback(
async (
const queueDeleteEdges = useCallback(
(
edgeIds: string[],
opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean },
) => {
if (!mapId || !edgeIds.length) return false;
if (!edgeIds.length) return false;
if (!opts?.skipConfirm) {
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
if (!window.confirm(confirmMsg)) return false;
}
pushHistory();
try {
await deleteFabricEdges(edgeIds);
const graph = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
setSelectedEdgeId((cur) => (cur && edgeIds.includes(cur) ? null : cur));
showOk(
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
);
return true;
} catch (err) {
showError(String(err));
return false;
}
for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id);
const idSet = new Set(edgeIds);
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
markDirty();
showOk(
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
);
return true;
},
[mapId, nodes, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, showOk, showError, t],
[pushHistory, setEdges, markDirty, showOk, t],
);
const removeSelected = useCallback(async () => {
const removeSelected = useCallback(() => {
if (!mapId) return;
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
const selectedDisplay = displayEdges.filter((e) => e.selected);
@ -2082,55 +2106,37 @@ export function TopologyPage() {
.replace("{{edges}}", String(edgeIds.size));
if (!window.confirm(msg)) return;
pushHistory();
try {
if (dirtyRef.current) {
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
await deleteFabricEdges([...edgeIds]);
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, nodeIds);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
setSelectedEdgeId(null);
showOk(
t("topology.selectionDeleted")
.replace("{{nodes}}", String(nodeIds.length))
.replace("{{edges}}", String(edgeIds.size)),
);
} catch (err) {
showError(String(err));
}
const nodeSet = new Set(nodeIds);
for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id);
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
setEdges((es) =>
es.filter(
(e) =>
!edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target),
),
);
setSelectedEdgeId(null);
markDirty();
showOk(
t("topology.selectionDeleted")
.replace("{{nodes}}", String(nodeIds.length))
.replace("{{edges}}", String(edgeIds.size)),
);
return;
}
if (!nodeIds.length && edgeIds.size) {
await persistDeleteEdges([...edgeIds]);
queueDeleteEdges([...edgeIds]);
return;
}
pushHistory();
try {
if (dirtyRef.current) {
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, nodeIds);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
setSelectedEdgeId(null);
} catch (err) {
showError(String(err));
}
const nodeSet = new Set(nodeIds);
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
// Drop incident edges from the canvas only — do not queue Fabric deletes.
setEdges((es) => es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target)));
setSelectedEdgeId(null);
markDirty();
}, [
mapId,
nodes,
@ -2139,12 +2145,9 @@ export function TopologyPage() {
setNodes,
setEdges,
pushHistory,
queryClient,
edgeDefaults,
clearDirty,
showError,
markDirty,
showOk,
persistDeleteEdges,
queueDeleteEdges,
t,
]);
@ -2152,7 +2155,7 @@ export function TopologyPage() {
const display = displayEdges.find((e) => e.id === edgeId);
const ids = physicalIdsForDisplayEdge(display, edges);
const list = ids.length ? ids : [edgeId];
void persistDeleteEdges(list);
queueDeleteEdges(list);
closeCtxMenu();
};
@ -2165,13 +2168,13 @@ export function TopologyPage() {
.map((e) => e.id);
}, [edges]);
const removeStaleEdges = useCallback(async () => {
const removeStaleEdges = useCallback(() => {
if (!staleEdgeIds.length) return;
await persistDeleteEdges(staleEdgeIds, {
queueDeleteEdges(staleEdgeIds, {
confirmKey: "topology.removeStaleHint",
okKey: "topology.staleRemoved",
});
}, [staleEdgeIds, persistDeleteEdges]);
}, [staleEdgeIds, queueDeleteEdges]);
const projectOutsidePeers = useCallback(async () => {
if (!mapId) return;
@ -2281,28 +2284,81 @@ export function TopologyPage() {
};
}, [ctxMenu, closeCtxMenu]);
const removeNodeById = async (nodeId: string) => {
const removeNodeById = (nodeId: string) => {
if (!mapId) return;
pushHistory();
closeCtxMenu();
try {
if (dirtyRef.current) {
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
} catch (err) {
showError(String(err));
}
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
markDirty();
};
const purgePlaceholderById = useCallback(
async (nodeId: string) => {
if (!mapId) return;
const node = nodes.find((n) => n.id === nodeId);
if (!node || !isPlaceholderSource(node.data.managed_source, node.data.ne_ip)) {
return;
}
if (!window.confirm(t("topology.deletePlaceholderConfirm"))) return;
closeCtxMenu();
try {
if (dirtyRef.current) {
const pendingEdges = [...pendingEdgeDeletesRef.current];
if (pendingEdges.length) {
await deleteFabricEdges(pendingEdges);
pendingEdgeDeletesRef.current.clear();
}
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = new Set(nodes.map((n) => n.id));
const toRemove = serverIds.filter((id) => !localIds.has(id) && id !== nodeId);
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
await patchTopologyPositions(
mapId,
flowToPositions(nodes.filter((n) => n.id !== nodeId)),
);
}
await purgePlaceholderFabricNodes([nodeId]);
const graph = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
const localPos = new Map(
nodes.filter((n) => n.id !== nodeId).map((n) => [n.id, n.position]),
);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
pendingEdgeDeletesRef.current = new Set();
historyRef.current = [];
redoRef.current = [];
bumpHistory();
clearDirty();
showOk(t("topology.deletePlaceholderDone"));
} catch (err) {
showError(String(err));
}
},
[
mapId,
nodes,
graphQuery.data,
closeCtxMenu,
queryClient,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
bumpHistory,
showOk,
showError,
t,
],
);
const openNeInventory = (opts: {
neId?: string;
create?: boolean;
@ -4521,6 +4577,19 @@ export function TopologyPage() {
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
{selectedNode &&
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => void purgePlaceholderById(ctxMenu.id)}
>
{t("topology.deletePlaceholder")}
</button>
</li>
) : null}
<li role="none">
<button
type="button"

View file

@ -1176,6 +1176,18 @@ export const deleteFabricNodes = (fabricNodeIds: string[]) =>
{ fabric_node_ids: fabricNodeIds },
);
/** Hard-delete topology/LLDP placeholders (ManagedNE + fabric + edges). */
export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) =>
apiPost<{
deleted: number;
edges_deleted: number;
placements_deleted: number;
managed_deleted: number;
membership_views: number;
}>("/v1/topology/fabric/nodes/purge-placeholders", {
fabric_node_ids: fabricNodeIds,
});
export const generateTopologySlices = (body: {
folder_id: string;
template: "core_only" | "core_agg" | "agg_access";