From 617d4245b0da18eb00f8b42da51e4a187ef46546 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 5 Aug 2026 21:23:00 +0800 Subject: [PATCH] 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 --- netx_api/topology_fabric.py | 4 + netx_api/topology_fabric_links.py | 35 +++ netx_api/topology_router.py | 18 ++ netx_api/topology_schemas.py | 9 + netx_api/topology_service.py | 4 + netx_api/topology_views_graph.py | 9 + tests/test_topology.py | 53 +++++ web/src/i18n/en.ts | 23 +- web/src/i18n/zh.ts | 22 +- web/src/index.css | 227 +++++-------------- web/src/pages/TopologyPage.tsx | 279 ++++++++++++++++++++---- web/src/pages/network/LldpLinksPage.tsx | 12 +- web/src/services/api.ts | 10 + web/src/types.ts | 1 + 14 files changed, 468 insertions(+), 238 deletions(-) diff --git a/netx_api/topology_fabric.py b/netx_api/topology_fabric.py index 66c40b8..bd9e27d 100644 --- a/netx_api/topology_fabric.py +++ b/netx_api/topology_fabric.py @@ -4,6 +4,8 @@ from __future__ import annotations from .topology_fabric_links import ( _apply_missing_and_purge, _mark_replaced_port_peers, + delete_fabric_edge, + delete_fabric_edges, merge_duplicate_fabric_nodes, upsert_fabric_edge, ) @@ -37,6 +39,8 @@ __all__ = [ "_match_hit_to_fabric_node", "_node_out", "_nodes_by_ids", + "delete_fabric_edge", + "delete_fabric_edges", "ensure_fabric_node_for_managed", "ensure_fabric_node_for_ume", "ensure_lldp_discovered_managed_ne", diff --git a/netx_api/topology_fabric_links.py b/netx_api/topology_fabric_links.py index 1b46dfc..2a5721d 100644 --- a/netx_api/topology_fabric_links.py +++ b/netx_api/topology_fabric_links.py @@ -511,4 +511,39 @@ def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]: return {"merged": merged, "placeholders_removed": placeholders_removed} +def delete_fabric_edges(db: Session, edge_ids: list[str]) -> dict[str, int]: + """Hard-delete fabric edges and any per-view edge styles.""" + ids = [str(x).strip() for x in (edge_ids or []) if str(x).strip()] + # De-dupe while preserving order. + seen: set[str] = set() + ordered: list[str] = [] + for i in ids: + if i in seen: + continue + seen.add(i) + ordered.append(i) + if not ordered: + raise HTTPException(status_code=400, detail="edge_ids_required") + rows = db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(ordered)).all() + found_ids = [str(r.id) for r in rows] + if not found_ids: + raise HTTPException(status_code=404, detail="fabric_edge_not_found") + db.query(TopoViewEdgeStyle).filter( + TopoViewEdgeStyle.fabric_edge_id.in_(found_ids) + ).delete(synchronize_session=False) + db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(found_ids)).delete( + synchronize_session=False + ) + db.commit() + try: + refresh_fabric_stats(db) + except Exception: # noqa: BLE001 + pass + return {"deleted": len(found_ids)} + + +def delete_fabric_edge(db: Session, edge_id: str) -> dict[str, int]: + return delete_fabric_edges(db, [edge_id]) + + diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index a1e3557..2cec3c6 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -26,6 +26,7 @@ from .topology_schemas import ( ClassifyRuleCreate, ClassifyRuleUpdate, FabricDiscoverRequest, + FabricEdgesDeleteRequest, FabricManualEdgeIn, FabricNodesBulkTagRequest, FabricNodesDeleteRequest, @@ -45,6 +46,8 @@ from .topology_schemas import ( ) from .topology_discover import get_discover_job, start_discover_job from .topology_fabric import ( + delete_fabric_edge, + delete_fabric_edges, get_fabric_neighborhood, get_fabric_summary, list_fabric_edges, @@ -175,6 +178,21 @@ def api_fabric_manual_edge( } +@router.delete("/fabric/edges/{edge_id}") +def api_fabric_delete_edge(edge_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + out = delete_fabric_edge(db, edge_id) + return {"ok": True, **out} + + +@router.post("/fabric/edges/delete") +def api_fabric_delete_edges( + body: FabricEdgesDeleteRequest, + db: Session = Depends(get_db), +) -> dict[str, Any]: + out = delete_fabric_edges(db, body.edge_ids) + return {"ok": True, **out} + + @router.post("/fabric/discover") def api_fabric_discover( body: FabricDiscoverRequest | None = None, diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 06eaacf..7ec40d9 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -259,6 +259,7 @@ class ViewNodeOut(BaseModel): vendor: str = "" device_type: str = "" connect_status: str = "" + managed_source: str = "" # manual | ume_sync | lldp | topology | webcrt | … class ViewEdgeOut(BaseModel): @@ -390,6 +391,14 @@ class FabricManualEdgeIn(BaseModel): b_port: str = "" +class FabricEdgesDeleteRequest(BaseModel): + edge_ids: list[str] = Field(default_factory=list, min_length=1) + + +class FabricEdgesDeleteOut(BaseModel): + deleted: int = 0 + + # --------------------------------------------------------------------------- # Classify rules + slices + search # --------------------------------------------------------------------------- diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 9214965..301c318 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -21,6 +21,8 @@ from .topology_fabric import ( _mark_replaced_port_peers, _match_hit_to_fabric_node, _node_out, + delete_fabric_edge, + delete_fabric_edges, ensure_fabric_node_for_managed, ensure_fabric_node_for_ume, ensure_lldp_discovered_managed_ne, @@ -68,6 +70,8 @@ __all__ = [ "create_folder", "create_topology_placeholder_on_view", "create_view", + "delete_fabric_edge", + "delete_fabric_edges", "delete_folder", "delete_view", "ensure_fabric_node_for_managed", diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index 6721aa5..69e4033 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -99,6 +99,14 @@ def _connect_status_for_node(db: Session, n: TopoFabricNode) -> str: return "" +def _managed_source_for_node(db: Session, n: TopoFabricNode) -> str: + if n.managed_ne_id: + ne = db.get(ManagedNE, n.managed_ne_id) + if ne is not None: + return str(ne.source or "").strip() + return "" + + def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut: view = _get_view_or_404(db, view_id) vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() @@ -135,6 +143,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut: vendor=(fn.vendor if fn else "") or "", device_type=(fn.device_type if fn else "") or "", connect_status=_connect_status_for_node(db, fn) if fn else "", + managed_source=_managed_source_for_node(db, fn) if fn else "", ) ) edges_out: list[ViewEdgeOut] = [] diff --git a/tests/test_topology.py b/tests/test_topology.py index 03bab17..d77fd14 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1693,6 +1693,59 @@ Management Addresses: self.assertGreaterEqual(removed.removed, 1) self.assertLess(removed.view_node_count, 5) + def test_delete_fabric_edge_removes_from_view_graph(self) -> None: + suffix = uuid4().hex[:8] + ne_a = ManagedNE( + id=f"dea-{suffix}", + name=f"DEA-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.77.{(int(suffix[:2], 16) % 200) + 1}.1", + source="manual", + ) + ne_b = ManagedNE( + id=f"deb-{suffix}", + name=f"DEB-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.77.{(int(suffix[:2], 16) % 200) + 1}.2", + source="manual", + ) + self.db.add(ne_a) + self.db.add(ne_b) + self.db.commit() + fa = svc.ensure_fabric_node_for_managed(self.db, ne_a) + fb = svc.ensure_fabric_node_for_managed(self.db, ne_b) + self.db.commit() + edge, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="manual", + ) + self.db.commit() + region_id = self._region(f"DelEdge-{suffix}") + view = svc.create_view( + self.db, + TopologyViewCreate(name=f"DE-{suffix}", folder_id=region_id), + ) + svc.add_nodes_to_view( + self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id]) + ) + graph = svc.get_view_graph(self.db, view.id) + self.assertTrue(any(e.id == edge.id for e in graph.edges)) + self.assertTrue(any(n.managed_source == "manual" for n in graph.nodes)) + + edge_id = edge.id + out = svc.delete_fabric_edges(self.db, [edge_id]) + self.assertEqual(out["deleted"], 1) + self.db.expire_all() + self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) + graph2 = svc.get_view_graph(self.db, view.id) + self.assertFalse(any(e.id == edge_id for e in graph2.edges)) + if __name__ == "__main__": unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4d09b69..bd20678 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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}})", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index eb453c2..02981ae 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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}})", diff --git a/web/src/index.css b/web/src/index.css index 916e1ec..5d2f9f7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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); } diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 3009224..9220b41 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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>) { 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
+ {placeholder ? ( + + {String(data.managed_source || "ph").slice(0, 4)} + + ) : null}
{name} @@ -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(); + 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() { )} {mapId && outsidePeers.length > 0 && ( -

- {t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))} -

+
+

+ {t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))} +

+ +
)}
@@ -3010,6 +3195,15 @@ export function TopologyPage() { > {t("topology.fit")} +
@@ -3142,6 +3336,7 @@ export function TopologyPage() { }} /> {t("topology.discoverProjectNeighbors")} + ({t("topology.localBrowserOnly")})
@@ -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 ? ( +
+ {truncateBannerText} +
+ ) : null} {fullscreen ? (
{( diff --git a/web/src/pages/network/LldpLinksPage.tsx b/web/src/pages/network/LldpLinksPage.tsx index 710116a..37703d3 100644 --- a/web/src/pages/network/LldpLinksPage.tsx +++ b/web/src/pages/network/LldpLinksPage.tsx @@ -742,10 +742,14 @@ export function LldpLinksPage() {

{t("lldpLinks.jobDetailTitle")}

{(() => { - 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); })()}

diff --git a/web/src/services/api.ts b/web/src/services/api.ts index e46f456..b07dac0 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1268,6 +1268,16 @@ export const createFabricManualEdge = (body: { b_port?: string; }) => apiPost<{ ok: boolean; action: string; edge: Record }>("/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; diff --git a/web/src/types.ts b/web/src/types.ts index 676dc01..4fab1c4 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -509,6 +509,7 @@ export type TopologyViewNodeItem = { vendor: string; device_type: string; connect_status: string; + managed_source?: string; }; export type TopologyViewEdgeItem = {