Harden topology canvas edge delete, discover cancel, and map UX.

Persist Fabric edge deletes, stop LLDP jobs from the canvas, surface truncation/placeholders/stale edges, and fix LLDP job deep-links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 21:23:00 +08:00
parent 5e567408e7
commit 617d4245b0
14 changed files with 468 additions and 238 deletions

View file

@ -1377,6 +1377,17 @@ const en = {
folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.",
folderHintShort: "Region folder",
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
projectNeighbors: "Project neighbors",
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)",
discoverCancelled: "Discovery cancelled",
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.",
truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.",
truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
newMap: "New",
newMapName: "New topology",
rename: "Rename",
@ -1399,7 +1410,7 @@ const en = {
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
saving: "Saving…",
saved: "Topology saved",
discover: "Discover all links",
discover: "Discover links",
discovering: "Discovering…",
discoverOne: "Discover links",
discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}",
@ -1541,16 +1552,6 @@ const en = {
discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed",
discoverNoTargets:
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
discoverFilter: "Result filter",
discoverFilterIssues: "Issues",
discoverFilterFail: "Failed",
discoverFilterWarn: "Warnings",
discoverFilterOk: "OK",
discoverFilterAll: "All",
discoverFilterDetail: "Details",
discoverListTitle: "Discovery details",
discoverListEmpty: "No NEs under the current filter.",
discoverShowLimited: "Showing first {{shown}} / {{total}} (narrow with filters)",
discoverViewDetail: "Detail",
discoverLinkCount: "{{count}} link(s)",
discoverLinksTitle: "Matched links ({{count}})",

View file

@ -1369,6 +1369,16 @@ const zh = {
folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。",
folderHintShort: "区域目录",
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
projectNeighbors: "投影邻居上图",
projectedNeighbors: "已投影 {{count}} 个邻居上图",
localBrowserOnly: "仅本浏览器",
deleteEdgeConfirm: "删除选中链路?将从 Fabric 全局移除;LLDP 下次采集若仍存在可能再次出现。",
edgeDeleted: "已删除 {{count}} 条链路",
discoverCancelled: "发现已取消",
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
newMap: "新建",
newMapName: "新拓扑图",
rename: "重命名",
@ -1391,7 +1401,7 @@ const zh = {
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
saving: "保存中…",
saved: "拓扑已保存",
discover: "发现全量链路",
discover: "发现链路",
discovering: "发现中…",
discoverOne: "发现链路",
discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
@ -1531,16 +1541,6 @@ const zh = {
discoverVendorRequired: "未设置厂商/设备类型,已跳过下发 LLDP 命令",
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
discoverFilter: "结果筛选",
discoverFilterIssues: "问题",
discoverFilterFail: "失败",
discoverFilterWarn: "警告",
discoverFilterOk: "成功",
discoverFilterAll: "全部",
discoverFilterDetail: "详情",
discoverListTitle: "发现详情",
discoverListEmpty: "当前筛选下没有网元。",
discoverShowLimited: "仅显示前 {{shown}} / {{total}} 条(请用筛选缩小范围)",
discoverViewDetail: "详情",
discoverLinkCount: "链路 {{count}}",
discoverLinksTitle: "匹配链路({{count}})",

View file

@ -6586,180 +6586,12 @@ pre {
color: #cbd5e1;
}
.topo-discover__filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.topo-discover__filter {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 999px;
background: rgba(30, 41, 59, 0.9);
font-size: 12px;
color: #cbd5e1;
cursor: pointer;
}
.topo-discover__filter.is-active {
border-color: #38bdf8;
background: rgba(14, 165, 233, 0.18);
color: #e0f2fe;
}
.topo-discover__filter--primary {
border-color: #0ea5e9;
background: #0284c7;
color: #fff;
}
.topo-discover__filter--primary .topo-discover__filter-count {
color: #fff;
}
.topo-discover__filter-count {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.topo-discover__empty-ok {
margin: 0;
font-size: 12px;
color: #86efac;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.topo-discover__link {
border: 0;
background: none;
padding: 0;
color: #38bdf8;
cursor: pointer;
font-size: 12px;
text-decoration: underline;
}
.topo-discover__cap {
margin: 0;
font-size: 11px;
color: #64748b;
}
.topo-discover__error {
margin: 0;
font-size: 13px;
color: #fca5a5;
}
.topo-discover__list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
.topo-discover__list--modal {
max-height: min(52vh, 480px);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px;
background: #f8fafc;
}
.topo-discover-list-modal {
width: min(880px, 100%);
}
.topo-discover__list > li {
margin-bottom: 2px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.topo-discover__list > li.is-fail {
border-color: #fecaca;
background: #fff1f2;
}
.topo-discover__list > li.is-warn {
border-color: #fde68a;
background: #fffbeb;
}
.topo-discover__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 5px 8px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.topo-discover__row-main {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.topo-discover__row-name {
font-weight: 600;
font-size: 12px;
color: #0f172a;
}
.topo-discover__row-ip {
font-size: 12px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.topo-discover__row-meta {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.topo-discover__badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
color: #166534;
}
.topo-discover__list > li.is-fail .topo-discover__badge {
color: #b91c1c;
}
.topo-discover__list > li.is-warn .topo-discover__badge {
color: #b45309;
}
.topo-discover__detail {
padding: 0 8px 6px;
border-top: 1px dashed #e2e8f0;
}
.topo-discover__item-meta,
.topo-discover__item-cmd,
.topo-discover__item-warn {
@ -6931,11 +6763,6 @@ pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.topo-discover-list-modal .topo-discover__list--modal {
max-height: min(52vh, 480px);
overflow: auto;
}
.topo-canvas {
position: relative;
flex: 1;
@ -7091,6 +6918,60 @@ pre {
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
}
.topo-node.is-placeholder .topo-node__glyph {
outline: 1px dashed rgba(148, 163, 184, 0.65);
outline-offset: 2px;
opacity: 0.88;
}
.topo-node__badge {
position: absolute;
top: -4px;
right: -6px;
z-index: 2;
min-width: 22px;
padding: 1px 4px;
border-radius: 4px;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(148, 163, 184, 0.45);
color: #fbbf24;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.02em;
pointer-events: none;
}
.topo-truncate-banner {
position: absolute;
top: 8px;
left: 50%;
z-index: 5;
transform: translateX(-50%);
max-width: min(720px, calc(100% - 24px));
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(251, 191, 36, 0.45);
background: rgba(15, 23, 42, 0.92);
color: #fde68a;
font-size: 12px;
line-height: 1.4;
text-align: center;
pointer-events: none;
}
.topo-outside-peers {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 10px 10px;
}
.topo-outside-peers .panel__hint {
margin: 0;
}
.topo-node--cisco {
--topo-icon-color: var(--topo-vendor-cisco, #049fd9);
}

View file

@ -38,6 +38,7 @@ import {
createTopologyFolder,
createTopologyPlaceholder,
createTopologyView,
deleteFabricEdges,
deleteTopologyFolder,
deleteTopologyMap,
fetchLldpCollectDashboard,
@ -53,6 +54,7 @@ import {
removeTopologyViewNodes,
searchFabricNodes,
startLldpDiscover,
stopLldpCollectJob,
updateLldpCollectPolicy,
updateTopologyFolder,
updateTopologyMap,
@ -266,6 +268,7 @@ type NeNodeData = {
ne_ip: string;
vendor: string;
connect_status: string;
managed_source?: string;
/** Canvas navigation node (not a fabric NE). */
kind?: "ne" | "region" | "layer";
folder_id?: string;
@ -403,9 +406,16 @@ const TOPO_ICON = 56;
const TOPO_HANDLE_X = TOPO_NODE_W / 2;
const TOPO_HANDLE_Y = TOPO_ICON / 2;
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
const src = String(source || "").trim().toLowerCase();
if (src === "lldp" || src === "topology") return true;
return !String(neIp || "").trim() && Boolean(src);
}
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext);
const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const placeholder = isPlaceholderSource(data.managed_source, data.ne_ip);
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
const secondary = [
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
@ -415,7 +425,8 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
<div
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
connectMode ? " is-connect-mode" : ""
}`}
}${placeholder ? " is-placeholder" : ""}`}
title={placeholder ? data.managed_source || "placeholder" : undefined}
>
<div className="topo-node__glyph">
<Handle
@ -431,6 +442,11 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
isConnectable={connectMode}
/>
<RouterIcon />
{placeholder ? (
<span className="topo-node__badge" aria-hidden>
{String(data.managed_source || "ph").slice(0, 4)}
</span>
) : null}
</div>
<div className="topo-node__caption">
<span className="topo-node__caption-name">{name}</span>
@ -725,6 +741,7 @@ function graphToFlow(
ne_ip: n.ip || "",
vendor: n.vendor || "",
connect_status: n.connect_status || "",
managed_source: n.managed_source || "",
},
}));
const rfEdges: Edge[] = edges.map((e) => {
@ -877,6 +894,8 @@ export function TopologyPage() {
edgesUpdated: 0,
});
const [discoverError, setDiscoverError] = useState("");
const [discoverJobId, setDiscoverJobId] = useState("");
const discoverAbortRef = useRef(false);
const [fullscreen, setFullscreen] = useState(false);
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
const [liveSync, setLiveSync] = useState(false);
@ -1516,6 +1535,8 @@ export function TopologyPage() {
setDiscoverReport(null);
setDiscoverLiveResults([]);
setDiscoverError("");
setDiscoverJobId("");
discoverAbortRef.current = false;
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
const scoped = scannable.filter(
(n) =>
@ -1546,9 +1567,19 @@ export function TopologyPage() {
auto_add_unmatched: discoverAutoAddUnmatched,
trigger_mode: "topology",
});
setDiscoverJobId(jobStart.id);
let job: TopologyDiscoverJob = jobStart;
let cancelled = false;
for (let i = 0; i < 600; i++) {
if (discoverAbortRef.current) {
cancelled = true;
break;
}
await new Promise((r) => window.setTimeout(r, 500));
if (discoverAbortRef.current) {
cancelled = true;
break;
}
job = await fetchLldpDiscoverJob(jobStart.id);
setDiscoverProgress((p) => ({
...p,
@ -1560,7 +1591,30 @@ export function TopologyPage() {
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
}));
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
if (job.status === "done" || job.status === "failed") break;
if (
job.status === "done" ||
job.status === "failed" ||
job.status === "cancelled" ||
job.status === "stopped"
) {
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
break;
}
}
if (cancelled || discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
setDiscoverReport({
map_id: mapId,
protocol: "lldp",
job_id: job.id,
scanned: job.done,
edges_added: job.edges_added,
edges_updated: job.edges_updated,
edges_stale: job.edges_missing ?? job.edges_stale,
results: (job.items || []) as TopologyDiscoverNeResult[],
graph: null,
});
return;
}
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
@ -1620,15 +1674,31 @@ export function TopologyPage() {
.replace("{{stale}}", String(out.edges_stale || 0)),
);
} catch (err) {
setDiscoverError(String(err));
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
if (discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
} else {
setDiscoverError(String(err));
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
}
} finally {
setDiscovering(false);
}
},
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty],
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, scheduleFitView],
);
const cancelDiscover = useCallback(async () => {
discoverAbortRef.current = true;
const jobId = discoverJobId;
if (jobId) {
try {
await stopLldpCollectJob(jobId);
} catch {
/* best-effort stop; local poll also exits */
}
}
}, [discoverJobId]);
const discoverResults = discoverReport?.results?.length
? discoverReport.results
: discoverLiveResults;
@ -1946,35 +2016,143 @@ export function TopologyPage() {
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
}, [setNodes, setEdges]);
const removeSelected = useCallback(async () => {
if (!mapId) return;
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
if (!nodeIds.length && !edgeIds.size) return;
pushHistory();
try {
if (nodeIds.length) {
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);
const persistDeleteEdges = useCallback(
async (edgeIds: string[], opts?: { confirmKey?: string; okKey?: string }) => {
if (!mapId || !edgeIds.length) return false;
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();
} else if (edgeIds.size) {
setEdges((eds) => eds.filter((e) => !edgeIds.has(e.id)));
markDirty();
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;
}
},
[mapId, nodes, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, showOk, showError, t],
);
const removeSelected = useCallback(async () => {
if (!mapId) return;
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
const selectedDisplay = displayEdges.filter((e) => e.selected);
const edgeIds = new Set<string>();
for (const de of selectedDisplay) {
for (const id of physicalIdsForDisplayEdge(de, edges)) edgeIds.add(id);
}
for (const e of edges) {
if (e.selected) edgeIds.add(e.id);
}
if (!nodeIds.length && !edgeIds.size) return;
if (!nodeIds.length && edgeIds.size) {
await persistDeleteEdges([...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));
}
}, [mapId, nodes, edges, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, markDirty, showError]);
}, [
mapId,
nodes,
edges,
displayEdges,
setNodes,
setEdges,
pushHistory,
queryClient,
edgeDefaults,
clearDirty,
showError,
persistDeleteEdges,
]);
const removeEdgeById = (edgeId: string) => {
const display = displayEdges.find((e) => e.id === edgeId);
const ids = physicalIdsForDisplayEdge(display, edges);
const list = ids.length ? ids : [edgeId];
void persistDeleteEdges(list);
closeCtxMenu();
};
const staleEdgeIds = useMemo(() => {
return edges
.filter((e) => {
const src = String((e.data as EdgeStyleData | undefined)?.source || "");
return src === "stale";
})
.map((e) => e.id);
}, [edges]);
const removeStaleEdges = useCallback(async () => {
if (!staleEdgeIds.length) return;
await persistDeleteEdges(staleEdgeIds, {
confirmKey: "topology.removeStaleHint",
okKey: "topology.staleRemoved",
});
}, [staleEdgeIds, persistDeleteEdges]);
const projectOutsidePeers = useCallback(async () => {
if (!mapId) return;
try {
const before = nodes.length;
const projected = await projectTopologyNeighbors(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
appliedMapIdRef.current = mapId;
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
historyLockRef.current = true;
applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
const added = Math.max(0, projected.nodes.length - before);
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added)));
if (added > 0) {
needsInitialFitRef.current = true;
scheduleFitView(FIT_VIEW_OPTS);
}
} catch (err) {
showError(String(err));
}
}, [
mapId,
nodes,
queryClient,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
showOk,
showError,
t,
scheduleFitView,
]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@ -2021,7 +2199,7 @@ export function TopologyPage() {
if (e.key === "Delete" || e.key === "Backspace") {
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
e.preventDefault();
removeSelected();
void removeSelected();
}
}
};
@ -2067,17 +2245,6 @@ export function TopologyPage() {
}
};
const removeEdgeById = (edgeId: string) => {
pushHistory();
markDirty();
const display = displayEdges.find((e) => e.id === edgeId);
const ids = new Set(physicalIdsForDisplayEdge(display, edges));
if (!ids.size) ids.add(edgeId);
setEdges((es) => es.filter((e) => !ids.has(e.id)));
setSelectedEdgeId((cur) => (cur && (ids.has(cur) || cur === edgeId) ? null : cur));
closeCtxMenu();
};
const openNeInventory = (opts: {
neId?: string;
create?: boolean;
@ -2492,6 +2659,15 @@ export function TopologyPage() {
);
const outsidePeers = graphQuery.data?.outside_peers || [];
const graphTruncated = Boolean(graphQuery.data?.truncated);
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
const truncateBannerText = useMemo(() => {
if (!graphTruncated) return "";
if (truncateReason === "membership_cap") return t("topology.truncatedMembership");
if (truncateReason === "too_many_view_nodes") return t("topology.truncatedNodes");
if (truncateReason === "too_many_edges") return t("topology.truncatedEdges");
return t("topology.truncatedGeneric");
}, [graphTruncated, truncateReason, t]);
const activeLeafName = useMemo(() => {
if (!mapId) return "";
if (activeView?.name) return activeView.name;
@ -2880,9 +3056,18 @@ export function TopologyPage() {
</ul>
)}
{mapId && outsidePeers.length > 0 && (
<p className="panel__hint">
{t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))}
</p>
<div className="topo-outside-peers">
<p className="panel__hint">
{t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))}
</p>
<button
type="button"
className="btn btn--sm"
onClick={() => void projectOutsidePeers()}
>
{t("topology.projectNeighbors")}
</button>
</div>
)}
</div>
</>
@ -3010,6 +3195,15 @@ export function TopologyPage() {
>
{t("topology.fit")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!staleEdgeIds.length}
title={t("topology.removeStaleHint")}
onClick={() => void removeStaleEdges()}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeIds.length))}
</button>
</div>
</div>
<div className="topo-toolbar__row topo-toolbar__row--tools">
@ -3142,6 +3336,7 @@ export function TopologyPage() {
}}
/>
{t("topology.discoverProjectNeighbors")}
<span className="muted"> ({t("topology.localBrowserOnly")})</span>
</label>
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
@ -3486,7 +3681,8 @@ export function TopologyPage() {
className="btn btn--sm btn--ghost"
onClick={() => {
if (discovering) {
return;
void cancelDiscover();
return;
}
setDiscoverOpen(false);
}}
@ -3733,6 +3929,11 @@ export function TopologyPage() {
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
>
{truncateBannerText ? (
<div className="topo-truncate-banner" role="status">
{truncateBannerText}
</div>
) : null}
{fullscreen ? (
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
{(

View file

@ -742,10 +742,14 @@ export function LldpLinksPage() {
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
<p className="muted">
{(() => {
const job = jobs.find((j) => j.id === expandedJobId);
return job
? `${job.id.slice(0, 8)} · ${job.status} · ${job.done}/${job.total}`
: expandedJobId.slice(0, 8);
const job = jobDetailQuery.data;
if (job) {
return `${job.id.slice(0, 8)} · ${job.status} · ${job.done}/${job.total}`;
}
if (jobDetailQuery.isLoading) {
return `${expandedJobId.slice(0, 8)} · …`;
}
return expandedJobId.slice(0, 8);
})()}
</p>
</div>

View file

@ -1268,6 +1268,16 @@ export const createFabricManualEdge = (body: {
b_port?: string;
}) => apiPost<{ ok: boolean; action: string; edge: Record<string, unknown> }>("/v1/topology/fabric/edges", body);
export const deleteFabricEdges = (edgeIds: string[]) =>
apiPost<{ ok: boolean; deleted: number }>("/v1/topology/fabric/edges/delete", {
edge_ids: edgeIds,
});
export const deleteFabricEdge = (edgeId: string) =>
apiDelete<{ ok: boolean; deleted: number }>(
`/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`,
);
/** Back-compat aliases used by older call sites during cutover. */
export const fetchTopologyMaps = fetchTopologyViews;
export const createTopologyMap = createTopologyView;

View file

@ -509,6 +509,7 @@ export type TopologyViewNodeItem = {
vendor: string;
device_type: string;
connect_status: string;
managed_source?: string;
};
export type TopologyViewEdgeItem = {