Stream topology discovery progress and mark missing links stale.

SSE updates the UI per NE; edges not seen in a successful scan turn red and can be cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-30 00:35:16 +08:00
parent bfc10ee8cf
commit bf82a01a03
11 changed files with 748 additions and 67 deletions

View file

@ -425,7 +425,7 @@ class TopologyEdge(Base):
target_node_id: Mapped[str] = mapped_column(String(64), index=True) target_node_id: Mapped[str] = mapped_column(String(64), index=True)
source_port: Mapped[str] = mapped_column(String(128), default="") source_port: Mapped[str] = mapped_column(String(128), default="")
target_port: Mapped[str] = mapped_column(String(128), default="") target_port: Mapped[str] = mapped_column(String(128), default="")
# manual | lldp | cdp # manual | lldp | cdp | stale
source: Mapped[str] = mapped_column(String(32), default="manual", index=True) source: Mapped[str] = mapped_column(String(32), default="manual", index=True)
discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View file

@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import json
from typing import Any, Iterator
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import get_db from .db import get_db
@ -19,6 +21,7 @@ from .topology_service import (
delete_map, delete_map,
discover_neighbors, discover_neighbors,
get_graph, get_graph,
iter_discover_neighbors,
list_maps, list_maps,
put_graph, put_graph,
update_map, update_map,
@ -27,6 +30,11 @@ from .topology_service import (
router = APIRouter(prefix="/v1/topology", tags=["topology"]) router = APIRouter(prefix="/v1/topology", tags=["topology"])
def _sse_pack(event: dict[str, Any]) -> str:
etype = str(event.get("type") or "message")
return f"event: {etype}\ndata: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
@router.get("/maps") @router.get("/maps")
def api_list_maps(db: Session = Depends(get_db)) -> dict[str, Any]: def api_list_maps(db: Session = Depends(get_db)) -> dict[str, Any]:
return list_maps(db) return list_maps(db)
@ -69,3 +77,30 @@ def api_discover(
) -> dict[str, Any]: ) -> dict[str, Any]:
req = body or TopologyDiscoverRequest() req = body or TopologyDiscoverRequest()
return discover_neighbors(db, map_id, req).model_dump() return discover_neighbors(db, map_id, req).model_dump()
@router.post("/maps/{map_id}/discover/stream")
def api_discover_stream(
map_id: str,
body: TopologyDiscoverRequest | None = None,
db: Session = Depends(get_db),
) -> StreamingResponse:
"""SSE stream: start → ne_start/ne_result (per NE) → done."""
req = body or TopologyDiscoverRequest()
def generate() -> Iterator[str]:
try:
for event in iter_discover_neighbors(db, map_id, req):
yield _sse_pack(event)
except Exception as exc: # noqa: BLE001 — surface to client then end stream
yield _sse_pack({"type": "error", "detail": str(exc)[:800]})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View file

@ -108,5 +108,6 @@ class TopologyDiscoverOut(BaseModel):
scanned: int = 0 scanned: int = 0
edges_added: int = 0 edges_added: int = 0
edges_updated: int = 0 edges_updated: int = 0
edges_stale: int = 0
results: list[TopologyDiscoverNeResult] = Field(default_factory=list) results: list[TopologyDiscoverNeResult] = Field(default_factory=list)
graph: TopologyGraphOut | None = None graph: TopologyGraphOut | None = None

View file

@ -221,7 +221,7 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
if sid not in node_ids or tid not in node_ids: if sid not in node_ids or tid not in node_ids:
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes") raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
src = str(e.source or "manual").strip().lower() or "manual" src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp"}: if src not in {"manual", "lldp", "cdp", "stale"}:
raise HTTPException(status_code=400, detail="invalid_edge_source") raise HTTPException(status_code=400, detail="invalid_edge_source")
now = _utcnow() now = _utcnow()
@ -243,6 +243,7 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
) )
) )
for e in edges_in: for e in edges_in:
src = str(e.source or "manual").strip().lower() or "manual"
db.add( db.add(
TopologyEdge( TopologyEdge(
id=str(e.id).strip() or uuid4().hex, id=str(e.id).strip() or uuid4().hex,
@ -251,8 +252,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
target_node_id=str(e.target_node_id).strip(), target_node_id=str(e.target_node_id).strip(),
source_port=str(e.source_port or "").strip()[:128], source_port=str(e.source_port or "").strip()[:128],
target_port=str(e.target_port or "").strip()[:128], target_port=str(e.target_port or "").strip()[:128],
source=str(e.source or "manual").strip().lower() or "manual", source=src,
discovered_at=now if str(e.source or "").lower() in {"lldp", "cdp"} else None, discovered_at=now if src in {"lldp", "cdp", "stale"} else None,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@ -306,11 +307,12 @@ def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[s
return (b, a, remote_port, local_port) return (b, a, remote_port, local_port)
def discover_neighbors( def iter_discover_neighbors(
db: Session, db: Session,
map_id: str, map_id: str,
body: TopologyDiscoverRequest, body: TopologyDiscoverRequest,
) -> TopologyDiscoverOut: ):
"""Yield discovery progress events: start / ne_start / ne_result / done / error."""
row = _get_map_or_404(db, map_id) row = _get_map_or_404(db, map_id)
nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all() nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all() edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
@ -325,7 +327,6 @@ def discover_neighbors(
and (not filter_ids or n.managed_ne_id in filter_ids) and (not filter_ids or n.managed_ne_id in filter_ids)
] ]
# Index existing edges for upsert (undirected + ports).
existing: dict[tuple[str, str, str, str], TopologyEdge] = {} existing: dict[tuple[str, str, str, str], TopologyEdge] = {}
for e in edges: for e in edges:
key = _edge_pair_key( key = _edge_pair_key(
@ -339,28 +340,55 @@ def discover_neighbors(
results: list[TopologyDiscoverNeResult] = [] results: list[TopologyDiscoverNeResult] = []
added = 0 added = 0
updated = 0 updated = 0
stale_count = 0
now = _utcnow() now = _utcnow()
proto_req = str(body.protocol or "auto").strip().lower() or "auto" proto_req = str(body.protocol or "auto").strip().lower() or "auto"
total = len(scan_nodes)
touched_edge_ids: set[str] = set()
scanned_ok_node_ids: set[str] = set()
for n in scan_nodes: yield {
"type": "start",
"map_id": row.id,
"protocol": proto_req,
"total": total,
}
for index, n in enumerate(scan_nodes, start=1):
ne = nes.get(n.managed_ne_id) ne = nes.get(n.managed_ne_id)
if ne is None: if ne is None:
continue continue
yield {
"type": "ne_start",
"index": index,
"total": total,
"ne_id": ne.id,
"ne_name": ne.name or "",
"ne_ip": ne.ip_address or "",
}
cmd, proto_tag = pick_neighbor_command( cmd, proto_tag = pick_neighbor_command(
protocol=proto_req, protocol=proto_req,
vendor=ne.vendor or "", vendor=ne.vendor or "",
device_type=ne.device_type or "", device_type=ne.device_type or "",
) )
if not cmd: if not cmd:
results.append( result = TopologyDiscoverNeResult(
TopologyDiscoverNeResult( ne_id=ne.id,
ne_id=ne.id, ne_name=ne.name or "",
ne_name=ne.name or "", ne_ip=ne.ip_address or "",
ne_ip=ne.ip_address or "", ok=False,
ok=False, error="no_command_for_vendor",
error="no_command_for_vendor",
)
) )
results.append(result)
yield {
"type": "ne_result",
"index": index,
"total": total,
"result": result.model_dump(mode="json"),
"edges_added": added,
"edges_updated": updated,
"edges_stale": stale_count,
}
continue continue
exec_out = execute_managed_ne_commands( exec_out = execute_managed_ne_commands(
db, db,
@ -369,18 +397,27 @@ def discover_neighbors(
read_timeout_sec=60, read_timeout_sec=60,
) )
if not exec_out.get("ok"): if not exec_out.get("ok"):
results.append( result = TopologyDiscoverNeResult(
TopologyDiscoverNeResult( ne_id=ne.id,
ne_id=ne.id, ne_name=ne.name or "",
ne_name=ne.name or "", ne_ip=ne.ip_address or "",
ne_ip=ne.ip_address or "", ok=False,
ok=False, command=cmd,
command=cmd, error=str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
error=str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
)
) )
results.append(result)
yield {
"type": "ne_result",
"index": index,
"total": total,
"result": result.model_dump(mode="json"),
"edges_added": added,
"edges_updated": updated,
"edges_stale": stale_count,
}
continue continue
scanned_ok_node_ids.add(n.id)
raw = str(exec_out.get("output") or "") raw = str(exec_out.get("output") or "")
hits = parse_neighbor_output( hits = parse_neighbor_output(
raw, raw,
@ -402,7 +439,6 @@ def discover_neighbors(
edge_proto = hit.protocol if hit.protocol in {"lldp", "cdp"} else proto_tag edge_proto = hit.protocol if hit.protocol in {"lldp", "cdp"} else proto_tag
cur = existing.get(key) cur = existing.get(key)
if cur is not None: if cur is not None:
# Never downgrade manual edges; refresh discovery metadata only for discovered.
if (cur.source or "manual") == "manual": if (cur.source or "manual") == "manual":
continue continue
cur.source = edge_proto cur.source = edge_proto
@ -410,10 +446,10 @@ def discover_neighbors(
cur.target_port = remote_port if cur.source_node_id == n.id else local_port cur.target_port = remote_port if cur.source_node_id == n.id else local_port
cur.discovered_at = now cur.discovered_at = now
cur.updated_at = now cur.updated_at = now
touched_edge_ids.add(cur.id)
ne_updated += 1 ne_updated += 1
updated += 1 updated += 1
continue continue
# Prefer orientation: scanning node as source.
new_edge = TopologyEdge( new_edge = TopologyEdge(
id=uuid4().hex, id=uuid4().hex,
map_id=row.id, map_id=row.id,
@ -428,32 +464,72 @@ def discover_neighbors(
) )
db.add(new_edge) db.add(new_edge)
existing[key] = new_edge existing[key] = new_edge
touched_edge_ids.add(new_edge.id)
ne_added += 1 ne_added += 1
added += 1 added += 1
results.append( result = TopologyDiscoverNeResult(
TopologyDiscoverNeResult( ne_id=ne.id,
ne_id=ne.id, ne_name=ne.name or "",
ne_name=ne.name or "", ne_ip=ne.ip_address or "",
ne_ip=ne.ip_address or "", ok=True,
ok=True, command=cmd,
command=cmd, neighbors=len(hits),
neighbors=len(hits), edges_added=ne_added,
edges_added=ne_added, edges_updated=ne_updated,
edges_updated=ne_updated, raw_preview=raw[:800],
raw_preview=raw[:800],
)
) )
results.append(result)
yield {
"type": "ne_result",
"index": index,
"total": total,
"result": result.model_dump(mode="json"),
"edges_added": added,
"edges_updated": updated,
"edges_stale": stale_count,
}
# Mark previously discovered edges not refreshed by this run as stale
# (only when at least one endpoint was successfully scanned).
if scanned_ok_node_ids:
for e in edges:
src = (e.source or "manual").strip().lower()
if src not in {"lldp", "cdp", "stale"}:
continue
if e.id in touched_edge_ids:
continue
if e.source_node_id not in scanned_ok_node_ids and e.target_node_id not in scanned_ok_node_ids:
continue
e.source = "stale"
e.updated_at = now
stale_count += 1
row.updated_at = now row.updated_at = now
db.commit() db.commit()
graph = get_graph(db, row.id) graph = get_graph(db, row.id)
return TopologyDiscoverOut( report = TopologyDiscoverOut(
map_id=row.id, map_id=row.id,
protocol=proto_req, protocol=proto_req,
scanned=len(results), scanned=len(results),
edges_added=added, edges_added=added,
edges_updated=updated, edges_updated=updated,
edges_stale=stale_count,
results=results, results=results,
graph=graph, graph=graph,
) )
yield {"type": "done", "report": report.model_dump(mode="json")}
def discover_neighbors(
db: Session,
map_id: str,
body: TopologyDiscoverRequest,
) -> TopologyDiscoverOut:
report: TopologyDiscoverOut | None = None
for event in iter_discover_neighbors(db, map_id, body):
if event.get("type") == "done":
report = TopologyDiscoverOut.model_validate(event.get("report") or {})
if report is None:
raise HTTPException(status_code=500, detail="discover_failed")
return report

View file

@ -397,6 +397,120 @@ class TopologyServiceTests(unittest.TestCase):
self.db.delete(ne_b) self.db.delete(ne_b)
self.db.commit() self.db.commit()
def test_iter_discover_emits_live_progress_events(self) -> None:
suffix = uuid4().hex[:8]
ne_a_id = f"nea-{suffix}"
ne_b_id = f"neb-{suffix}"
ip_a = f"198.51.100.{(int(suffix[:2], 16) % 100) + 1}"
ip_b = f"198.51.100.{(int(suffix[2:4], 16) % 100) + 101}"
ne_a = ManagedNE(
id=ne_a_id,
name="R2",
vendor="Cisco",
device_type="cisco_ios",
ip_address=ip_a,
)
ne_b = ManagedNE(
id=ne_b_id,
name="R1",
vendor="Cisco",
device_type="cisco_ios",
ip_address=ip_b,
)
self.db.add(ne_a)
self.db.add(ne_b)
self.db.commit()
created = svc.create_map(self.db, TopologyMapCreate(name=f"Stream-{suffix}"))
mid = created.id
svc.put_graph(
self.db,
mid,
TopologyGraphPut(
nodes=[
TopologyNodeIn(id="n1", managed_ne_id=ne_a_id, label="R2", x=0, y=0),
TopologyNodeIn(id="n2", managed_ne_id=ne_b_id, label="R1", x=100, y=0),
],
edges=[],
),
)
fake_exec = {
"ok": True,
"output": CISCO_CDP_DETAIL,
"commands": ["show cdp neighbors detail"],
}
events: list[str] = []
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
for ev in svc.iter_discover_neighbors(
self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id])
):
events.append(str(ev.get("type") or ""))
self.assertEqual(events[0], "start")
self.assertIn("ne_start", events)
self.assertIn("ne_result", events)
self.assertEqual(events[-1], "done")
svc.delete_map(self.db, mid)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def test_discover_marks_missing_edges_stale(self) -> None:
suffix = uuid4().hex[:8]
ne_a_id = f"nea-{suffix}"
ne_b_id = f"neb-{suffix}"
ip_a = f"203.0.113.{(int(suffix[:2], 16) % 80) + 10}"
ip_b = f"203.0.113.{(int(suffix[2:4], 16) % 80) + 100}"
ne_a = ManagedNE(
id=ne_a_id,
name="R2",
vendor="Cisco",
device_type="cisco_ios",
ip_address=ip_a,
)
ne_b = ManagedNE(
id=ne_b_id,
name="R1",
vendor="Cisco",
device_type="cisco_ios",
ip_address=ip_b,
)
self.db.add(ne_a)
self.db.add(ne_b)
self.db.commit()
mid = svc.create_map(self.db, TopologyMapCreate(name=f"Stale-{suffix}")).id
svc.put_graph(
self.db,
mid,
TopologyGraphPut(
nodes=[
TopologyNodeIn(id="n1", managed_ne_id=ne_a_id, label="R2", x=0, y=0),
TopologyNodeIn(id="n2", managed_ne_id=ne_b_id, label="R1", x=100, y=0),
],
edges=[],
),
)
with_neighbors = {
"ok": True,
"output": CISCO_CDP_DETAIL,
"commands": ["show cdp neighbors detail"],
}
empty = {"ok": True, "output": "Total entries displayed: 0\n", "commands": ["show cdp neighbors detail"]}
with patch.object(svc, "execute_managed_ne_commands", return_value=with_neighbors):
out1 = svc.discover_neighbors(
self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id])
)
self.assertGreaterEqual(out1.edges_added, 1)
with patch.object(svc, "execute_managed_ne_commands", return_value=empty):
out2 = svc.discover_neighbors(
self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id])
)
self.assertGreaterEqual(out2.edges_stale, 1)
graph = svc.get_graph(self.db, mid)
self.assertTrue(any(e.source == "stale" for e in graph.edges))
svc.delete_map(self.db, mid)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -611,17 +611,20 @@ const en = {
saved: "Topology saved", saved: "Topology saved",
discover: "Discover links", discover: "Discover links",
discovering: "Discovering…", discovering: "Discovering…",
discovered: "Discovery done: +{{added}} updated {{updated}}", discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}",
discoverFail: "Discovery failed: {{detail}}", discoverFail: "Discovery failed: {{detail}}",
selectMap: "Select or create a topology map", selectMap: "Select or create a topology map",
canvasHint: "Add NEs from the left, drag and connect; dashed edges are LLDP/CDP", canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable)",
selected: "Selected", selected: "Selected",
selectedEdge: "Selected edge",
openWebcrt: "Open terminal", openWebcrt: "Open terminal",
openNe: "NE details", openNe: "NE details",
removeNode: "Remove from canvas", removeNode: "Remove from canvas",
removeEdge: "Delete edge",
noNeLink: "Not linked to a managed NE", noNeLink: "Not linked to a managed NE",
edgeManual: "Manual", edgeManual: "Manual",
edgeDiscovered: "Discovered", edgeDiscovered: "Discovered",
edgeStale: "Missing",
fit: "Fit view", fit: "Fit view",
display: "Display", display: "Display",
hideIp: "Hide IP", hideIp: "Hide IP",
@ -637,6 +640,16 @@ const en = {
paletteManaged: "Managed", paletteManaged: "Managed",
paletteUme: "UME", paletteUme: "UME",
paletteLoading: "Loading…", paletteLoading: "Loading…",
discoverReport: "Discovery progress",
discoverClose: "Close",
discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…",
discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}",
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}",
discoverNeFail: "Collect failed",
removeStale: "Clear missing ({{count}})",
removeStaleHint: "Delete red-marked missing edges",
staleRemoved: "Removed {{count}} missing edge(s)",
}, },
}; };

View file

@ -609,17 +609,20 @@ const zh = {
saved: "拓扑已保存", saved: "拓扑已保存",
discover: "发现链路", discover: "发现链路",
discovering: "发现中…", discovering: "发现中…",
discovered: "发现完成:新增 {{added}},更新 {{updated}}", discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
discoverFail: "发现失败:{{detail}}", discoverFail: "发现失败:{{detail}}",
selectMap: "请选择或新建一张拓扑图", selectMap: "请选择或新建一张拓扑图",
canvasHint: "从左侧添加网元,拖动节点并连线;虚线为 LLDP/CDP 发现链路", canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删)",
selected: "已选节点", selected: "已选节点",
selectedEdge: "已选链路",
openWebcrt: "打开终端", openWebcrt: "打开终端",
openNe: "网元详情", openNe: "网元详情",
removeNode: "从画布移除", removeNode: "从画布移除",
removeEdge: "删除链路",
noNeLink: "未关联托管网元", noNeLink: "未关联托管网元",
edgeManual: "人工", edgeManual: "人工",
edgeDiscovered: "发现", edgeDiscovered: "发现",
edgeStale: "未发现",
fit: "适应画布", fit: "适应画布",
display: "显示选项", display: "显示选项",
hideIp: "隐藏 IP", hideIp: "隐藏 IP",
@ -635,6 +638,16 @@ const zh = {
paletteManaged: "托管网元", paletteManaged: "托管网元",
paletteUme: "UME 网元", paletteUme: "UME 网元",
paletteLoading: "加载中…", paletteLoading: "加载中…",
discoverReport: "发现进度",
discoverClose: "关闭",
discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…",
discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}",
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}",
discoverNeFail: "采集失败",
removeStale: "清除未发现 ({{count}})",
removeStaleHint: "删除红色标记的未发现链路",
staleRemoved: "已删除 {{count}} 条未发现链路",
}, },
}; };

View file

@ -2089,6 +2089,88 @@ pre {
gap: 8px; gap: 8px;
} }
.topo-discover {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
padding: 10px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
}
.topo-discover__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.topo-discover__summary {
margin: 0;
font-size: 13px;
color: #334155;
}
.topo-discover__error {
margin: 0;
font-size: 13px;
color: #b91c1c;
}
.topo-discover__list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
.topo-discover__list li {
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
background: #fff;
border: 1px solid #e2e8f0;
}
.topo-discover__list li.is-fail {
border-color: #fecaca;
background: #fff1f2;
}
.topo-discover__item-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 600;
color: #0f172a;
}
.topo-discover__badge {
font-size: 11px;
font-weight: 700;
color: #166534;
}
.topo-discover__list li.is-fail .topo-discover__badge {
color: #b91c1c;
}
.topo-discover__item-meta,
.topo-discover__item-cmd {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
.topo-discover__item-cmd {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.topo-canvas { .topo-canvas {
position: relative; position: relative;
flex: 1; flex: 1;

View file

@ -22,7 +22,7 @@ import "@xyflow/react/dist/style.css";
import { import {
createTopologyMap, createTopologyMap,
deleteTopologyMap, deleteTopologyMap,
discoverTopologyNeighbors, discoverTopologyNeighborsStream,
fetchManagedNe, fetchManagedNe,
fetchTopologyGraph, fetchTopologyGraph,
fetchTopologyMaps, fetchTopologyMaps,
@ -34,7 +34,14 @@ import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows"; import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem, UmeNeItem } from "../types"; import type {
ManagedNeItem,
TopologyDiscoverNeResult,
TopologyDiscoverOut,
TopologyEdgeItem,
TopologyNodeItem,
UmeNeItem,
} from "../types";
type NeNodeData = { type NeNodeData = {
label: string; label: string;
@ -140,6 +147,17 @@ function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const nodeTypes = { neNode: NeNode }; const nodeTypes = { neNode: NeNode };
function edgeStyle(source: string): { stroke: string; strokeDasharray?: string; strokeWidth?: number } {
const src = (source || "manual").toLowerCase();
if (src === "stale") {
return { stroke: "#dc2626", strokeDasharray: "4 4", strokeWidth: 2 };
}
if (src === "lldp" || src === "cdp") {
return { stroke: "#0ea5e9", strokeDasharray: "6 4" };
}
return { stroke: "#64748b" };
}
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) { function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({ const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
id: n.id, id: n.id,
@ -155,7 +173,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
}, },
})); }));
const rfEdges: Edge[] = edges.map((e) => { const rfEdges: Edge[] = edges.map((e) => {
const discovered = e.source === "lldp" || e.source === "cdp"; const src = e.source || "manual";
const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ "); const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
return { return {
id: e.id, id: e.id,
@ -164,12 +182,10 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
type: "straight", type: "straight",
label: label || undefined, label: label || undefined,
animated: false, animated: false,
style: discovered style: edgeStyle(src),
? { stroke: "#0ea5e9", strokeDasharray: "6 4" }
: { stroke: "#64748b" },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 }, markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
data: { data: {
source: e.source || "manual", source: src,
source_port: e.source_port || "", source_port: e.source_port || "",
target_port: e.target_port || "", target_port: e.target_port || "",
}, },
@ -206,6 +222,7 @@ export function TopologyPage() {
const [mapId, setMapId] = useState<string>(""); const [mapId, setMapId] = useState<string>("");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [hideIp, setHideIp] = useState(true); const [hideIp, setHideIp] = useState(true);
const [hideVendor, setHideVendor] = useState(true); const [hideVendor, setHideVendor] = useState(true);
const [hidePorts, setHidePorts] = useState(true); const [hidePorts, setHidePorts] = useState(true);
@ -213,6 +230,20 @@ export function TopologyPage() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [hideAddedNes, setHideAddedNes] = useState(true); const [hideAddedNes, setHideAddedNes] = useState(true);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed"); const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [discoverOpen, setDiscoverOpen] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
const [discoverLiveResults, setDiscoverLiveResults] = useState<TopologyDiscoverNeResult[]>([]);
const [discoverProgress, setDiscoverProgress] = useState({
index: 0,
total: 0,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
const [discoverError, setDiscoverError] = useState("");
const discoverAbortRef = useRef<AbortController | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null); const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
@ -338,14 +369,60 @@ export function TopologyPage() {
onError: (err) => showError(String(err)), onError: (err) => showError(String(err)),
}); });
const discoverMut = useMutation({ const runDiscover = useCallback(async () => {
mutationFn: async () => { if (!mapId || discovering) return;
discoverAbortRef.current?.abort();
const ac = new AbortController();
discoverAbortRef.current = ac;
setDiscoverOpen(true);
setDiscovering(true);
setDiscoverReport(null);
setDiscoverLiveResults([]);
setDiscoverError("");
setDiscoverProgress({
index: 0,
total: nodes.filter((n) => Boolean(n.data.managed_ne_id)).length,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
try {
if (dirtyRef.current) { if (dirtyRef.current) {
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)); await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
dirtyRef.current = false;
} }
return discoverTopologyNeighbors(mapId, { protocol: "auto" }); const out = await discoverTopologyNeighborsStream(
}, mapId,
onSuccess: async (out) => { { protocol: "auto" },
{
onStart: (ev) => {
setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 }));
},
onNeStart: (ev) => {
setDiscoverProgress((p) => ({
...p,
index: ev.index,
total: ev.total,
neName: ev.ne_name || ev.ne_ip || ev.ne_id,
neIp: ev.ne_ip,
}));
},
onNeResult: (ev) => {
if (ev.result) {
setDiscoverLiveResults((prev) => [...prev, ev.result]);
}
setDiscoverProgress((p) => ({
...p,
index: ev.index,
total: ev.total,
edgesAdded: ev.edges_added,
edgesUpdated: ev.edges_updated,
}));
},
},
ac.signal,
);
if (out.graph) { if (out.graph) {
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph); queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges); const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
@ -353,16 +430,42 @@ export function TopologyPage() {
setEdges(rfEdges); setEdges(rfEdges);
dirtyRef.current = false; dirtyRef.current = false;
} }
setDiscoverReport(out);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk( showOk(
t("topology.discovered") t("topology.discovered")
.replace("{{added}}", String(out.edges_added)) .replace("{{added}}", String(out.edges_added))
.replace("{{updated}}", String(out.edges_updated)), .replace("{{updated}}", String(out.edges_updated))
.replace("{{stale}}", String(out.edges_stale || 0)),
); );
}, } catch (err) {
onError: (err) => if (ac.signal.aborted) return;
showError(t("topology.discoverFail").replace("{{detail}}", String(err))), setDiscoverError(String(err));
}); showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
} finally {
if (discoverAbortRef.current === ac) discoverAbortRef.current = null;
setDiscovering(false);
}
}, [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t]);
const discoverResults = discoverReport?.results?.length
? discoverReport.results
: discoverLiveResults;
const discoverSummary = discoverReport
? {
scanned: discoverReport.scanned,
added: discoverReport.edges_added,
updated: discoverReport.edges_updated,
stale: discoverReport.edges_stale || 0,
failed: discoverReport.results.filter((r) => !r.ok).length,
}
: {
scanned: discoverLiveResults.length,
added: discoverProgress.edgesAdded,
updated: discoverProgress.edgesUpdated,
stale: 0,
failed: discoverLiveResults.filter((r) => !r.ok).length,
};
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
@ -455,8 +558,24 @@ export function TopologyPage() {
() => nodes.find((n) => n.id === selectedNodeId) || null, () => nodes.find((n) => n.id === selectedNodeId) || null,
[nodes, selectedNodeId], [nodes, selectedNodeId],
); );
const selectedEdge = useMemo(
() => edges.find((e) => e.id === selectedEdgeId) || null,
[edges, selectedEdgeId],
);
const staleEdgeCount = useMemo(
() =>
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
.length,
[edges],
);
const removeSelected = () => { const removeSelected = () => {
if (selectedEdgeId) {
dirtyRef.current = true;
setEdges((es) => es.filter((e) => e.id !== selectedEdgeId));
setSelectedEdgeId(null);
return;
}
if (!selectedNodeId) return; if (!selectedNodeId) return;
dirtyRef.current = true; dirtyRef.current = true;
setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId)); setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId));
@ -464,6 +583,17 @@ export function TopologyPage() {
setSelectedNodeId(null); setSelectedNodeId(null);
}; };
const removeStaleEdges = () => {
const n = staleEdgeCount;
if (n <= 0) return;
dirtyRef.current = true;
setEdges((es) =>
es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"),
);
setSelectedEdgeId(null);
showOk(t("topology.staleRemoved").replace("{{count}}", String(n)));
};
const openWebcrt = () => { const openWebcrt = () => {
const managedId = selectedNode?.data.managed_ne_id; const managedId = selectedNode?.data.managed_ne_id;
const umeId = selectedNode?.data.ume_ne_id; const umeId = selectedNode?.data.ume_ne_id;
@ -790,10 +920,19 @@ export function TopologyPage() {
<button <button
type="button" type="button"
className="btn btn--sm" className="btn btn--sm"
disabled={!mapId || discoverMut.isPending} disabled={!mapId || discovering}
onClick={() => discoverMut.mutate()} onClick={() => void runDiscover()}
> >
{discoverMut.isPending ? t("topology.discovering") : t("topology.discover")} {discovering ? t("topology.discovering") : t("topology.discover")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!mapId || discovering || staleEdgeCount <= 0}
onClick={removeStaleEdges}
title={t("topology.removeStaleHint")}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</button> </button>
<button <button
type="button" type="button"
@ -824,6 +963,83 @@ export function TopologyPage() {
</button> </button>
</div> </div>
</div> </div>
) : selectedEdge ? (
<div className="topo-selection">
<span>
{t("topology.selectedEdge")}:{" "}
<strong>
{String((selectedEdge.data as { source?: string } | undefined)?.source || "manual")}
</strong>
{selectedEdge.label ? ` · ${String(selectedEdge.label)}` : ""}
</span>
<div className="topo-selection__actions">
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
{t("topology.removeEdge")}
</button>
</div>
</div>
) : null}
{discoverOpen ? (
<div className="topo-discover">
<div className="topo-discover__head">
<strong>{t("topology.discoverReport")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setDiscoverOpen(false)}
disabled={discovering}
>
{t("topology.discoverClose")}
</button>
</div>
{discovering ? (
<p className="panel__hint panel__hint--live">
{t("topology.discoverProgressLive")
.replace("{{index}}", String(discoverProgress.index))
.replace("{{total}}", String(discoverProgress.total))
.replace(
"{{name}}",
discoverProgress.neName || discoverProgress.neIp || "…",
)}
</p>
) : null}
{discoverError ? <p className="topo-discover__error">{discoverError}</p> : null}
{discoverResults.length > 0 || discoverReport ? (
<>
<p className="topo-discover__summary">
{t("topology.discoverSummary")
.replace("{{scanned}}", String(discoverSummary.scanned))
.replace("{{added}}", String(discoverSummary.added))
.replace("{{updated}}", String(discoverSummary.updated))
.replace("{{stale}}", String(discoverSummary.stale))
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<ul className="topo-discover__list">
{discoverResults.map((r) => (
<li key={r.ne_id} className={r.ok ? "is-ok" : "is-fail"}>
<div className="topo-discover__item-title">
<span>{r.ne_name || r.ne_ip || r.ne_id}</span>
<span className="topo-discover__badge">{r.ok ? "OK" : "FAIL"}</span>
</div>
<div className="topo-discover__item-meta">
{r.ne_ip ? `${r.ne_ip} · ` : ""}
{r.ok
? t("topology.discoverNeOk")
.replace("{{neighbors}}", String(r.neighbors))
.replace("{{added}}", String(r.edges_added))
.replace("{{updated}}", String(r.edges_updated))
: r.error || t("topology.discoverNeFail")}
</div>
{r.command ? (
<div className="topo-discover__item-cmd">{r.command}</div>
) : null}
</li>
))}
</ul>
</>
) : null}
</div>
) : null} ) : null}
<div className="topo-canvas"> <div className="topo-canvas">
@ -844,13 +1060,25 @@ export function TopologyPage() {
onEdgesChange(changes); onEdgesChange(changes);
}} }}
onConnect={onConnect} onConnect={onConnect}
onNodeClick={(_e, node) => setSelectedNodeId(node.id)} onNodeClick={(_e, node) => {
onPaneClick={() => setSelectedNodeId(null)} setSelectedEdgeId(null);
setSelectedNodeId(node.id);
}}
onEdgeClick={(_e, edge) => {
setSelectedNodeId(null);
setSelectedEdgeId(edge.id);
}}
onPaneClick={() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
onInit={(inst) => { onInit={(inst) => {
rfRef.current = inst; rfRef.current = inst;
}} }}
fitView fitView
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={["Backspace", "Delete"]}
edgesFocusable
elementsSelectable
> >
<Background gap={18} size={1} /> <Background gap={18} size={1} />
<Controls /> <Controls />

View file

@ -19,6 +19,8 @@ import type {
CliTargetListResponse, CliTargetListResponse,
UmeCliOverrideItem, UmeCliOverrideItem,
TopologyDiscoverOut, TopologyDiscoverOut,
TopologyDiscoverNeResult,
TopologyDiscoverStreamHandlers,
TopologyGraph, TopologyGraph,
TopologyMapItem, TopologyMapItem,
} from "../types"; } from "../types";
@ -473,3 +475,99 @@ export const discoverTopologyNeighbors = (
`/v1/topology/maps/${encodeURIComponent(mapId)}/discover`, `/v1/topology/maps/${encodeURIComponent(mapId)}/discover`,
body || {}, body || {},
); );
function parseSseChunks(buffer: string): { events: Array<{ event: string; data: string }>; rest: string } {
const events: Array<{ event: string; data: string }> = [];
let rest = buffer;
while (true) {
const sep = rest.indexOf("\n\n");
if (sep < 0) break;
const raw = rest.slice(0, sep);
rest = rest.slice(sep + 2);
let event = "message";
const dataLines: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (dataLines.length) events.push({ event, data: dataLines.join("\n") });
}
return { events, rest };
}
export async function discoverTopologyNeighborsStream(
mapId: string,
body: { protocol?: string; ne_ids?: string[] } | undefined,
handlers: TopologyDiscoverStreamHandlers,
signal?: AbortSignal,
): Promise<TopologyDiscoverOut> {
const res = await fetch(`/v1/topology/maps/${encodeURIComponent(mapId)}/discover/stream`, {
method: "POST",
headers: {
accept: "text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify(body || {}),
signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `discover_stream_failed:${res.status}`);
}
if (!res.body) throw new Error("discover_stream_empty_body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let finalReport: TopologyDiscoverOut | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parsed = parseSseChunks(buf);
buf = parsed.rest;
for (const item of parsed.events) {
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(item.data) as Record<string, unknown>;
} catch {
continue;
}
const type = String(payload.type || item.event || "");
if (type === "start") {
handlers.onStart?.({
map_id: String(payload.map_id || ""),
protocol: String(payload.protocol || ""),
total: Number(payload.total || 0),
});
} else if (type === "ne_start") {
handlers.onNeStart?.({
index: Number(payload.index || 0),
total: Number(payload.total || 0),
ne_id: String(payload.ne_id || ""),
ne_name: String(payload.ne_name || ""),
ne_ip: String(payload.ne_ip || ""),
});
} else if (type === "ne_result") {
handlers.onNeResult?.({
index: Number(payload.index || 0),
total: Number(payload.total || 0),
result: payload.result as TopologyDiscoverNeResult,
edges_added: Number(payload.edges_added || 0),
edges_updated: Number(payload.edges_updated || 0),
});
} else if (type === "done") {
finalReport = payload.report as TopologyDiscoverOut;
if (finalReport) handlers.onDone?.(finalReport);
} else if (type === "error") {
const detail = String(payload.detail || "discover_stream_error");
handlers.onError?.(detail);
throw new Error(detail);
}
}
}
if (!finalReport) throw new Error("discover_stream_incomplete");
return finalReport;
}

View file

@ -405,6 +405,27 @@ export type TopologyDiscoverOut = {
scanned: number; scanned: number;
edges_added: number; edges_added: number;
edges_updated: number; edges_updated: number;
edges_stale?: number;
results: TopologyDiscoverNeResult[]; results: TopologyDiscoverNeResult[];
graph: TopologyGraph | null; graph: TopologyGraph | null;
}; };
export type TopologyDiscoverStreamHandlers = {
onStart?: (ev: { map_id: string; protocol: string; total: number }) => void;
onNeStart?: (ev: {
index: number;
total: number;
ne_id: string;
ne_name: string;
ne_ip: string;
}) => void;
onNeResult?: (ev: {
index: number;
total: number;
result: TopologyDiscoverNeResult;
edges_added: number;
edges_updated: number;
}) => void;
onDone?: (report: TopologyDiscoverOut) => void;
onError?: (detail: string) => void;
};