mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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:
parent
2aafb72676
commit
6e2c8517cf
16 changed files with 732 additions and 166 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue