mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 05:20:47 +08:00
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:
parent
5e567408e7
commit
617d4245b0
14 changed files with 468 additions and 238 deletions
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
||||||
from .topology_fabric_links import (
|
from .topology_fabric_links import (
|
||||||
_apply_missing_and_purge,
|
_apply_missing_and_purge,
|
||||||
_mark_replaced_port_peers,
|
_mark_replaced_port_peers,
|
||||||
|
delete_fabric_edge,
|
||||||
|
delete_fabric_edges,
|
||||||
merge_duplicate_fabric_nodes,
|
merge_duplicate_fabric_nodes,
|
||||||
upsert_fabric_edge,
|
upsert_fabric_edge,
|
||||||
)
|
)
|
||||||
|
|
@ -37,6 +39,8 @@ __all__ = [
|
||||||
"_match_hit_to_fabric_node",
|
"_match_hit_to_fabric_node",
|
||||||
"_node_out",
|
"_node_out",
|
||||||
"_nodes_by_ids",
|
"_nodes_by_ids",
|
||||||
|
"delete_fabric_edge",
|
||||||
|
"delete_fabric_edges",
|
||||||
"ensure_fabric_node_for_managed",
|
"ensure_fabric_node_for_managed",
|
||||||
"ensure_fabric_node_for_ume",
|
"ensure_fabric_node_for_ume",
|
||||||
"ensure_lldp_discovered_managed_ne",
|
"ensure_lldp_discovered_managed_ne",
|
||||||
|
|
|
||||||
|
|
@ -511,4 +511,39 @@ def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]:
|
||||||
return {"merged": merged, "placeholders_removed": placeholders_removed}
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from .topology_schemas import (
|
||||||
ClassifyRuleCreate,
|
ClassifyRuleCreate,
|
||||||
ClassifyRuleUpdate,
|
ClassifyRuleUpdate,
|
||||||
FabricDiscoverRequest,
|
FabricDiscoverRequest,
|
||||||
|
FabricEdgesDeleteRequest,
|
||||||
FabricManualEdgeIn,
|
FabricManualEdgeIn,
|
||||||
FabricNodesBulkTagRequest,
|
FabricNodesBulkTagRequest,
|
||||||
FabricNodesDeleteRequest,
|
FabricNodesDeleteRequest,
|
||||||
|
|
@ -45,6 +46,8 @@ from .topology_schemas import (
|
||||||
)
|
)
|
||||||
from .topology_discover import get_discover_job, start_discover_job
|
from .topology_discover import get_discover_job, start_discover_job
|
||||||
from .topology_fabric import (
|
from .topology_fabric import (
|
||||||
|
delete_fabric_edge,
|
||||||
|
delete_fabric_edges,
|
||||||
get_fabric_neighborhood,
|
get_fabric_neighborhood,
|
||||||
get_fabric_summary,
|
get_fabric_summary,
|
||||||
list_fabric_edges,
|
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")
|
@router.post("/fabric/discover")
|
||||||
def api_fabric_discover(
|
def api_fabric_discover(
|
||||||
body: FabricDiscoverRequest | None = None,
|
body: FabricDiscoverRequest | None = None,
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ class ViewNodeOut(BaseModel):
|
||||||
vendor: str = ""
|
vendor: str = ""
|
||||||
device_type: str = ""
|
device_type: str = ""
|
||||||
connect_status: str = ""
|
connect_status: str = ""
|
||||||
|
managed_source: str = "" # manual | ume_sync | lldp | topology | webcrt | …
|
||||||
|
|
||||||
|
|
||||||
class ViewEdgeOut(BaseModel):
|
class ViewEdgeOut(BaseModel):
|
||||||
|
|
@ -390,6 +391,14 @@ class FabricManualEdgeIn(BaseModel):
|
||||||
b_port: str = ""
|
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
|
# Classify rules + slices + search
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ from .topology_fabric import (
|
||||||
_mark_replaced_port_peers,
|
_mark_replaced_port_peers,
|
||||||
_match_hit_to_fabric_node,
|
_match_hit_to_fabric_node,
|
||||||
_node_out,
|
_node_out,
|
||||||
|
delete_fabric_edge,
|
||||||
|
delete_fabric_edges,
|
||||||
ensure_fabric_node_for_managed,
|
ensure_fabric_node_for_managed,
|
||||||
ensure_fabric_node_for_ume,
|
ensure_fabric_node_for_ume,
|
||||||
ensure_lldp_discovered_managed_ne,
|
ensure_lldp_discovered_managed_ne,
|
||||||
|
|
@ -68,6 +70,8 @@ __all__ = [
|
||||||
"create_folder",
|
"create_folder",
|
||||||
"create_topology_placeholder_on_view",
|
"create_topology_placeholder_on_view",
|
||||||
"create_view",
|
"create_view",
|
||||||
|
"delete_fabric_edge",
|
||||||
|
"delete_fabric_edges",
|
||||||
"delete_folder",
|
"delete_folder",
|
||||||
"delete_view",
|
"delete_view",
|
||||||
"ensure_fabric_node_for_managed",
|
"ensure_fabric_node_for_managed",
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,14 @@ def _connect_status_for_node(db: Session, n: TopoFabricNode) -> str:
|
||||||
return ""
|
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:
|
def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||||
view = _get_view_or_404(db, view_id)
|
view = _get_view_or_404(db, view_id)
|
||||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
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 "",
|
vendor=(fn.vendor if fn else "") or "",
|
||||||
device_type=(fn.device_type if fn else "") or "",
|
device_type=(fn.device_type if fn else "") or "",
|
||||||
connect_status=_connect_status_for_node(db, fn) if fn else "",
|
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] = []
|
edges_out: list[ViewEdgeOut] = []
|
||||||
|
|
|
||||||
|
|
@ -1693,6 +1693,59 @@ Management Addresses:
|
||||||
self.assertGreaterEqual(removed.removed, 1)
|
self.assertGreaterEqual(removed.removed, 1)
|
||||||
self.assertLess(removed.view_node_count, 5)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -1377,6 +1377,17 @@ const en = {
|
||||||
folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.",
|
folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.",
|
||||||
folderHintShort: "Region folder",
|
folderHintShort: "Region folder",
|
||||||
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
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",
|
newMap: "New",
|
||||||
newMapName: "New topology",
|
newMapName: "New topology",
|
||||||
rename: "Rename",
|
rename: "Rename",
|
||||||
|
|
@ -1399,7 +1410,7 @@ const en = {
|
||||||
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
saved: "Topology saved",
|
saved: "Topology saved",
|
||||||
discover: "Discover all links",
|
discover: "Discover links",
|
||||||
discovering: "Discovering…",
|
discovering: "Discovering…",
|
||||||
discoverOne: "Discover links",
|
discoverOne: "Discover links",
|
||||||
discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}",
|
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",
|
discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed",
|
||||||
discoverNoTargets:
|
discoverNoTargets:
|
||||||
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
|
"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",
|
discoverViewDetail: "Detail",
|
||||||
discoverLinkCount: "{{count}} link(s)",
|
discoverLinkCount: "{{count}} link(s)",
|
||||||
discoverLinksTitle: "Matched links ({{count}})",
|
discoverLinksTitle: "Matched links ({{count}})",
|
||||||
|
|
|
||||||
|
|
@ -1369,6 +1369,16 @@ const zh = {
|
||||||
folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。",
|
folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。",
|
||||||
folderHintShort: "区域目录",
|
folderHintShort: "区域目录",
|
||||||
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||||
|
projectNeighbors: "投影邻居上图",
|
||||||
|
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
||||||
|
localBrowserOnly: "仅本浏览器",
|
||||||
|
deleteEdgeConfirm: "删除选中链路?将从 Fabric 全局移除;LLDP 下次采集若仍存在可能再次出现。",
|
||||||
|
edgeDeleted: "已删除 {{count}} 条链路",
|
||||||
|
discoverCancelled: "发现已取消",
|
||||||
|
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
|
||||||
|
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
|
||||||
|
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
|
||||||
|
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
|
||||||
newMap: "新建",
|
newMap: "新建",
|
||||||
newMapName: "新拓扑图",
|
newMapName: "新拓扑图",
|
||||||
rename: "重命名",
|
rename: "重命名",
|
||||||
|
|
@ -1391,7 +1401,7 @@ const zh = {
|
||||||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||||
saving: "保存中…",
|
saving: "保存中…",
|
||||||
saved: "拓扑已保存",
|
saved: "拓扑已保存",
|
||||||
discover: "发现全量链路",
|
discover: "发现链路",
|
||||||
discovering: "发现中…",
|
discovering: "发现中…",
|
||||||
discoverOne: "发现链路",
|
discoverOne: "发现链路",
|
||||||
discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
|
discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
|
||||||
|
|
@ -1531,16 +1541,6 @@ const zh = {
|
||||||
discoverVendorRequired: "未设置厂商/设备类型,已跳过下发 LLDP 命令",
|
discoverVendorRequired: "未设置厂商/设备类型,已跳过下发 LLDP 命令",
|
||||||
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
|
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
|
||||||
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
|
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
|
||||||
discoverFilter: "结果筛选",
|
|
||||||
discoverFilterIssues: "问题",
|
|
||||||
discoverFilterFail: "失败",
|
|
||||||
discoverFilterWarn: "警告",
|
|
||||||
discoverFilterOk: "成功",
|
|
||||||
discoverFilterAll: "全部",
|
|
||||||
discoverFilterDetail: "详情",
|
|
||||||
discoverListTitle: "发现详情",
|
|
||||||
discoverListEmpty: "当前筛选下没有网元。",
|
|
||||||
discoverShowLimited: "仅显示前 {{shown}} / {{total}} 条(请用筛选缩小范围)",
|
|
||||||
discoverViewDetail: "详情",
|
discoverViewDetail: "详情",
|
||||||
discoverLinkCount: "链路 {{count}}",
|
discoverLinkCount: "链路 {{count}}",
|
||||||
discoverLinksTitle: "匹配链路({{count}})",
|
discoverLinksTitle: "匹配链路({{count}})",
|
||||||
|
|
|
||||||
|
|
@ -6586,180 +6586,12 @@ pre {
|
||||||
color: #cbd5e1;
|
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 {
|
.topo-discover__error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #fca5a5;
|
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-meta,
|
||||||
.topo-discover__item-cmd,
|
.topo-discover__item-cmd,
|
||||||
.topo-discover__item-warn {
|
.topo-discover__item-warn {
|
||||||
|
|
@ -6931,11 +6763,6 @@ pre {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
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 {
|
.topo-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -7091,6 +6918,60 @@ pre {
|
||||||
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
|
--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-node--cisco {
|
||||||
--topo-icon-color: var(--topo-vendor-cisco, #049fd9);
|
--topo-icon-color: var(--topo-vendor-cisco, #049fd9);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import {
|
||||||
createTopologyFolder,
|
createTopologyFolder,
|
||||||
createTopologyPlaceholder,
|
createTopologyPlaceholder,
|
||||||
createTopologyView,
|
createTopologyView,
|
||||||
|
deleteFabricEdges,
|
||||||
deleteTopologyFolder,
|
deleteTopologyFolder,
|
||||||
deleteTopologyMap,
|
deleteTopologyMap,
|
||||||
fetchLldpCollectDashboard,
|
fetchLldpCollectDashboard,
|
||||||
|
|
@ -53,6 +54,7 @@ import {
|
||||||
removeTopologyViewNodes,
|
removeTopologyViewNodes,
|
||||||
searchFabricNodes,
|
searchFabricNodes,
|
||||||
startLldpDiscover,
|
startLldpDiscover,
|
||||||
|
stopLldpCollectJob,
|
||||||
updateLldpCollectPolicy,
|
updateLldpCollectPolicy,
|
||||||
updateTopologyFolder,
|
updateTopologyFolder,
|
||||||
updateTopologyMap,
|
updateTopologyMap,
|
||||||
|
|
@ -266,6 +268,7 @@ type NeNodeData = {
|
||||||
ne_ip: string;
|
ne_ip: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
connect_status: string;
|
connect_status: string;
|
||||||
|
managed_source?: string;
|
||||||
/** Canvas navigation node (not a fabric NE). */
|
/** Canvas navigation node (not a fabric NE). */
|
||||||
kind?: "ne" | "region" | "layer";
|
kind?: "ne" | "region" | "layer";
|
||||||
folder_id?: string;
|
folder_id?: string;
|
||||||
|
|
@ -403,9 +406,16 @@ const TOPO_ICON = 56;
|
||||||
const TOPO_HANDLE_X = TOPO_NODE_W / 2;
|
const TOPO_HANDLE_X = TOPO_NODE_W / 2;
|
||||||
const TOPO_HANDLE_Y = TOPO_ICON / 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 NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||||
const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext);
|
const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext);
|
||||||
const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
|
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 name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
|
||||||
const secondary = [
|
const secondary = [
|
||||||
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
|
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
|
<div
|
||||||
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
||||||
connectMode ? " is-connect-mode" : ""
|
connectMode ? " is-connect-mode" : ""
|
||||||
}`}
|
}${placeholder ? " is-placeholder" : ""}`}
|
||||||
|
title={placeholder ? data.managed_source || "placeholder" : undefined}
|
||||||
>
|
>
|
||||||
<div className="topo-node__glyph">
|
<div className="topo-node__glyph">
|
||||||
<Handle
|
<Handle
|
||||||
|
|
@ -431,6 +442,11 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
isConnectable={connectMode}
|
isConnectable={connectMode}
|
||||||
/>
|
/>
|
||||||
<RouterIcon />
|
<RouterIcon />
|
||||||
|
{placeholder ? (
|
||||||
|
<span className="topo-node__badge" aria-hidden>
|
||||||
|
{String(data.managed_source || "ph").slice(0, 4)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-node__caption">
|
<div className="topo-node__caption">
|
||||||
<span className="topo-node__caption-name">{name}</span>
|
<span className="topo-node__caption-name">{name}</span>
|
||||||
|
|
@ -725,6 +741,7 @@ function graphToFlow(
|
||||||
ne_ip: n.ip || "",
|
ne_ip: n.ip || "",
|
||||||
vendor: n.vendor || "",
|
vendor: n.vendor || "",
|
||||||
connect_status: n.connect_status || "",
|
connect_status: n.connect_status || "",
|
||||||
|
managed_source: n.managed_source || "",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const rfEdges: Edge[] = edges.map((e) => {
|
const rfEdges: Edge[] = edges.map((e) => {
|
||||||
|
|
@ -877,6 +894,8 @@ export function TopologyPage() {
|
||||||
edgesUpdated: 0,
|
edgesUpdated: 0,
|
||||||
});
|
});
|
||||||
const [discoverError, setDiscoverError] = useState("");
|
const [discoverError, setDiscoverError] = useState("");
|
||||||
|
const [discoverJobId, setDiscoverJobId] = useState("");
|
||||||
|
const discoverAbortRef = useRef(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
|
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
|
||||||
const [liveSync, setLiveSync] = useState(false);
|
const [liveSync, setLiveSync] = useState(false);
|
||||||
|
|
@ -1516,6 +1535,8 @@ export function TopologyPage() {
|
||||||
setDiscoverReport(null);
|
setDiscoverReport(null);
|
||||||
setDiscoverLiveResults([]);
|
setDiscoverLiveResults([]);
|
||||||
setDiscoverError("");
|
setDiscoverError("");
|
||||||
|
setDiscoverJobId("");
|
||||||
|
discoverAbortRef.current = false;
|
||||||
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||||
const scoped = scannable.filter(
|
const scoped = scannable.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
|
|
@ -1546,9 +1567,19 @@ export function TopologyPage() {
|
||||||
auto_add_unmatched: discoverAutoAddUnmatched,
|
auto_add_unmatched: discoverAutoAddUnmatched,
|
||||||
trigger_mode: "topology",
|
trigger_mode: "topology",
|
||||||
});
|
});
|
||||||
|
setDiscoverJobId(jobStart.id);
|
||||||
let job: TopologyDiscoverJob = jobStart;
|
let job: TopologyDiscoverJob = jobStart;
|
||||||
|
let cancelled = false;
|
||||||
for (let i = 0; i < 600; i++) {
|
for (let i = 0; i < 600; i++) {
|
||||||
|
if (discoverAbortRef.current) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
await new Promise((r) => window.setTimeout(r, 500));
|
await new Promise((r) => window.setTimeout(r, 500));
|
||||||
|
if (discoverAbortRef.current) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
job = await fetchLldpDiscoverJob(jobStart.id);
|
job = await fetchLldpDiscoverJob(jobStart.id);
|
||||||
setDiscoverProgress((p) => ({
|
setDiscoverProgress((p) => ({
|
||||||
...p,
|
...p,
|
||||||
|
|
@ -1560,7 +1591,30 @@ export function TopologyPage() {
|
||||||
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
||||||
}));
|
}));
|
||||||
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
|
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") {
|
if (job.status === "failed") {
|
||||||
throw new Error(job.error || "discover_failed");
|
throw new Error(job.error || "discover_failed");
|
||||||
|
|
@ -1620,15 +1674,31 @@ export function TopologyPage() {
|
||||||
.replace("{{stale}}", String(out.edges_stale || 0)),
|
.replace("{{stale}}", String(out.edges_stale || 0)),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDiscoverError(String(err));
|
if (discoverAbortRef.current) {
|
||||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
setDiscoverError(t("topology.discoverCancelled"));
|
||||||
|
} else {
|
||||||
|
setDiscoverError(String(err));
|
||||||
|
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setDiscovering(false);
|
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
|
const discoverResults = discoverReport?.results?.length
|
||||||
? discoverReport.results
|
? discoverReport.results
|
||||||
: discoverLiveResults;
|
: discoverLiveResults;
|
||||||
|
|
@ -1946,35 +2016,143 @@ export function TopologyPage() {
|
||||||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||||
}, [setNodes, setEdges]);
|
}, [setNodes, setEdges]);
|
||||||
|
|
||||||
const removeSelected = useCallback(async () => {
|
const persistDeleteEdges = useCallback(
|
||||||
if (!mapId) return;
|
async (edgeIds: string[], opts?: { confirmKey?: string; okKey?: string }) => {
|
||||||
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
|
if (!mapId || !edgeIds.length) return false;
|
||||||
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
|
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
|
||||||
if (!nodeIds.length && !edgeIds.size) return;
|
if (!window.confirm(confirmMsg)) return false;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
try {
|
try {
|
||||||
if (nodeIds.length) {
|
await deleteFabricEdges(edgeIds);
|
||||||
if (dirtyRef.current) {
|
const graph = await fetchTopologyGraph(mapId);
|
||||||
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);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
appliedMapIdRef.current = mapId;
|
appliedMapIdRef.current = mapId;
|
||||||
|
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
clearDirty();
|
clearDirty();
|
||||||
} else if (edgeIds.size) {
|
setSelectedEdgeId((cur) => (cur && edgeIds.includes(cur) ? null : cur));
|
||||||
setEdges((eds) => eds.filter((e) => !edgeIds.has(e.id)));
|
showOk(
|
||||||
markDirty();
|
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);
|
setSelectedEdgeId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -2021,7 +2199,7 @@ export function TopologyPage() {
|
||||||
if (e.key === "Delete" || e.key === "Backspace") {
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
|
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
|
||||||
e.preventDefault();
|
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: {
|
const openNeInventory = (opts: {
|
||||||
neId?: string;
|
neId?: string;
|
||||||
create?: boolean;
|
create?: boolean;
|
||||||
|
|
@ -2492,6 +2659,15 @@ export function TopologyPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const outsidePeers = graphQuery.data?.outside_peers || [];
|
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(() => {
|
const activeLeafName = useMemo(() => {
|
||||||
if (!mapId) return "";
|
if (!mapId) return "";
|
||||||
if (activeView?.name) return activeView.name;
|
if (activeView?.name) return activeView.name;
|
||||||
|
|
@ -2880,9 +3056,18 @@ export function TopologyPage() {
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{mapId && outsidePeers.length > 0 && (
|
{mapId && outsidePeers.length > 0 && (
|
||||||
<p className="panel__hint">
|
<div className="topo-outside-peers">
|
||||||
{t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))}
|
<p className="panel__hint">
|
||||||
</p>
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -3010,6 +3195,15 @@ export function TopologyPage() {
|
||||||
>
|
>
|
||||||
{t("topology.fit")}
|
{t("topology.fit")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div className="topo-toolbar__row topo-toolbar__row--tools">
|
<div className="topo-toolbar__row topo-toolbar__row--tools">
|
||||||
|
|
@ -3142,6 +3336,7 @@ export function TopologyPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{t("topology.discoverProjectNeighbors")}
|
{t("topology.discoverProjectNeighbors")}
|
||||||
|
<span className="muted"> ({t("topology.localBrowserOnly")})</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
||||||
<div className="topo-display-defaults__head">
|
<div className="topo-display-defaults__head">
|
||||||
|
|
@ -3486,7 +3681,8 @@ export function TopologyPage() {
|
||||||
className="btn btn--sm btn--ghost"
|
className="btn btn--sm btn--ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (discovering) {
|
if (discovering) {
|
||||||
return;
|
void cancelDiscover();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setDiscoverOpen(false);
|
setDiscoverOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|
@ -3733,6 +3929,11 @@ export function TopologyPage() {
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
onDrop={onCanvasDrop}
|
onDrop={onCanvasDrop}
|
||||||
>
|
>
|
||||||
|
{truncateBannerText ? (
|
||||||
|
<div className="topo-truncate-banner" role="status">
|
||||||
|
{truncateBannerText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{fullscreen ? (
|
{fullscreen ? (
|
||||||
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
|
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
|
||||||
{(
|
{(
|
||||||
|
|
|
||||||
|
|
@ -742,10 +742,14 @@ export function LldpLinksPage() {
|
||||||
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
|
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{(() => {
|
{(() => {
|
||||||
const job = jobs.find((j) => j.id === expandedJobId);
|
const job = jobDetailQuery.data;
|
||||||
return job
|
if (job) {
|
||||||
? `${job.id.slice(0, 8)} · ${job.status} · ${job.done}/${job.total}`
|
return `${job.id.slice(0, 8)} · ${job.status} · ${job.done}/${job.total}`;
|
||||||
: expandedJobId.slice(0, 8);
|
}
|
||||||
|
if (jobDetailQuery.isLoading) {
|
||||||
|
return `${expandedJobId.slice(0, 8)} · …`;
|
||||||
|
}
|
||||||
|
return expandedJobId.slice(0, 8);
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1268,6 +1268,16 @@ export const createFabricManualEdge = (body: {
|
||||||
b_port?: string;
|
b_port?: string;
|
||||||
}) => apiPost<{ ok: boolean; action: string; edge: Record<string, unknown> }>("/v1/topology/fabric/edges", body);
|
}) => 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. */
|
/** Back-compat aliases used by older call sites during cutover. */
|
||||||
export const fetchTopologyMaps = fetchTopologyViews;
|
export const fetchTopologyMaps = fetchTopologyViews;
|
||||||
export const createTopologyMap = createTopologyView;
|
export const createTopologyMap = createTopologyView;
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,7 @@ export type TopologyViewNodeItem = {
|
||||||
vendor: string;
|
vendor: string;
|
||||||
device_type: string;
|
device_type: string;
|
||||||
connect_status: string;
|
connect_status: string;
|
||||||
|
managed_source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TopologyViewEdgeItem = {
|
export type TopologyViewEdgeItem = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue