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

@ -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.",

View file

@ -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: "没有网元。",

View file

@ -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;

View file

@ -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")}
</button>
<span className="panel__hint">
{t("topoClassify.selectedCount").replace("{{count}}", String(selectedCount))}
<button
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>
</div>
@ -475,13 +535,35 @@ export function TopologyClassifyPage() {
</button>
</div>
) : (
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => startEdit(n)}
>
{t("topoClassify.edit")}
</button>
<div className="topo-classify__row-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => startEdit(n)}
>
{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>
</tr>

View file

@ -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";

View file

@ -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;