Allow topology discovery to scan UME nodes on the map.

Previously only managed NEs were scanned, so UME-only maps reported Scanned 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-30 00:42:34 +08:00
parent bf82a01a03
commit ec736b6dc8
4 changed files with 122 additions and 47 deletions

View file

@ -9,6 +9,7 @@ from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
from .ne_exec import execute_managed_ne_commands from .ne_exec import execute_managed_ne_commands
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
@ -278,6 +279,7 @@ def _match_neighbor_to_node(
*, *,
nodes: list[TopologyNode], nodes: list[TopologyNode],
nes: dict[str, ManagedNE], nes: dict[str, ManagedNE],
umes: dict[str, UmeInventoryNE],
self_node_id: str, self_node_id: str,
) -> TopologyNode | None: ) -> TopologyNode | None:
name_key = _norm_key(hit.remote_name) name_key = _norm_key(hit.remote_name)
@ -286,17 +288,20 @@ def _match_neighbor_to_node(
if n.id == self_node_id: if n.id == self_node_id:
continue continue
ne = nes.get(str(n.managed_ne_id or "")) ne = nes.get(str(n.managed_ne_id or ""))
ume = umes.get(str(n.ume_ne_id or ""))
candidates = [ candidates = [
_norm_key(n.label or ""), _norm_key(n.label or ""),
_norm_key(ne.name if ne else ""), _norm_key(ne.name if ne else ""),
str(ne.ip_address if ne else "").strip(), str(ne.ip_address if ne else "").strip(),
_norm_key(ume.host_name if ume else ""),
_norm_key(ume.ne_name if ume else ""),
_norm_key(ume.user_label if ume else ""),
str(ume.ip_address if ume else "").strip(),
] ]
if ip_key and ip_key in candidates: cand_set = {_norm_key(c) for c in candidates if str(c or "").strip()}
if ip_key and ip_key in {str(c).strip() for c in candidates if str(c or "").strip()}:
return n return n
if name_key and name_key in {_norm_key(c) for c in candidates if c}: if name_key and name_key in cand_set:
return n
# Also match managed NE name without case
if ne and name_key and name_key == _norm_key(ne.name):
return n return n
return None return None
@ -307,6 +312,47 @@ 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_target_for_node(
n: TopologyNode,
*,
nes: dict[str, ManagedNE],
umes: dict[str, UmeInventoryNE],
filter_ids: set[str],
default_profile,
) -> dict[str, str] | None:
"""Resolve CLI target for a topology node (managed preferred, else UME)."""
mid = str(n.managed_ne_id or "").strip()
uid = str(n.ume_ne_id or "").strip()
if filter_ids and mid not in filter_ids and uid not in filter_ids:
return None
if mid and mid in nes:
ne = nes[mid]
return {
"ne_id": ne.id,
"ume_ne_id": "",
"ne_name": ne.name or "",
"ne_ip": ne.ip_address or "",
"vendor": ne.vendor or "",
"device_type": ne.device_type or "",
}
if uid and uid in umes:
ume = umes[uid]
if default_profile is not None:
dtype, vendor = infer_device_type_vendor(str(ume.ne_type or ""), default_profile)
else:
dtype, vendor = "zte_zxros", (ume.vendor or "ZTE")
name = (ume.host_name or ume.ne_name or ume.user_label or ume.ip_address or uid).strip()
return {
"ne_id": uid,
"ume_ne_id": uid,
"ne_name": name,
"ne_ip": ume.ip_address or "",
"vendor": vendor or (ume.vendor or "ZTE"),
"device_type": dtype or "zte_zxros",
}
return None
def iter_discover_neighbors( def iter_discover_neighbors(
db: Session, db: Session,
map_id: str, map_id: str,
@ -317,15 +363,17 @@ def iter_discover_neighbors(
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()
nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id}) nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id})
umes = _ume_lookup(db, {str(n.ume_ne_id or "") for n in nodes if n.ume_ne_id})
default_profile = get_default_profile(db)
filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()} filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()}
scan_nodes = [ scan_targets: list[tuple[TopologyNode, dict[str, str]]] = []
n for n in nodes:
for n in nodes target = _discover_target_for_node(
if n.managed_ne_id n, nes=nes, umes=umes, filter_ids=filter_ids, default_profile=default_profile
and n.managed_ne_id in nes )
and (not filter_ids or n.managed_ne_id in filter_ids) if target is not None:
] scan_targets.append((n, target))
existing: dict[tuple[str, str, str, str], TopologyEdge] = {} existing: dict[tuple[str, str, str, str], TopologyEdge] = {}
for e in edges: for e in edges:
@ -343,7 +391,7 @@ def iter_discover_neighbors(
stale_count = 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) total = len(scan_targets)
touched_edge_ids: set[str] = set() touched_edge_ids: set[str] = set()
scanned_ok_node_ids: set[str] = set() scanned_ok_node_ids: set[str] = set()
@ -354,28 +402,25 @@ def iter_discover_neighbors(
"total": total, "total": total,
} }
for index, n in enumerate(scan_nodes, start=1): for index, (n, target) in enumerate(scan_targets, start=1):
ne = nes.get(n.managed_ne_id)
if ne is None:
continue
yield { yield {
"type": "ne_start", "type": "ne_start",
"index": index, "index": index,
"total": total, "total": total,
"ne_id": ne.id, "ne_id": target["ne_id"],
"ne_name": ne.name or "", "ne_name": target["ne_name"],
"ne_ip": ne.ip_address or "", "ne_ip": target["ne_ip"],
} }
cmd, proto_tag = pick_neighbor_command( cmd, proto_tag = pick_neighbor_command(
protocol=proto_req, protocol=proto_req,
vendor=ne.vendor or "", vendor=target["vendor"],
device_type=ne.device_type or "", device_type=target["device_type"],
) )
if not cmd: if not cmd:
result = TopologyDiscoverNeResult( result = TopologyDiscoverNeResult(
ne_id=ne.id, ne_id=target["ne_id"],
ne_name=ne.name or "", ne_name=target["ne_name"],
ne_ip=ne.ip_address or "", ne_ip=target["ne_ip"],
ok=False, ok=False,
error="no_command_for_vendor", error="no_command_for_vendor",
) )
@ -390,17 +435,39 @@ def iter_discover_neighbors(
"edges_stale": stale_count, "edges_stale": stale_count,
} }
continue continue
exec_out = execute_managed_ne_commands(
db, exec_kwargs: dict[str, Any] = {"read_timeout_sec": 60}
[cmd], if target["ume_ne_id"] and not str(n.managed_ne_id or "").strip():
ne_id=ne.id, exec_kwargs["ume_ne_id"] = target["ume_ne_id"]
read_timeout_sec=60, else:
) exec_kwargs["ne_id"] = target["ne_id"]
try:
exec_out = execute_managed_ne_commands(db, [cmd], **exec_kwargs)
except HTTPException as exc:
result = TopologyDiscoverNeResult(
ne_id=target["ne_id"],
ne_name=target["ne_name"],
ne_ip=target["ne_ip"],
ok=False,
command=cmd,
error=str(exc.detail 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
if not exec_out.get("ok"): if not exec_out.get("ok"):
result = TopologyDiscoverNeResult( result = TopologyDiscoverNeResult(
ne_id=ne.id, ne_id=target["ne_id"],
ne_name=ne.name or "", ne_name=target["ne_name"],
ne_ip=ne.ip_address or "", ne_ip=target["ne_ip"],
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],
@ -422,14 +489,14 @@ def iter_discover_neighbors(
hits = parse_neighbor_output( hits = parse_neighbor_output(
raw, raw,
protocol=proto_tag, protocol=proto_tag,
vendor=ne.vendor or "", vendor=target["vendor"],
device_type=ne.device_type or "", device_type=target["device_type"],
) )
ne_added = 0 ne_added = 0
ne_updated = 0 ne_updated = 0
for hit in hits: for hit in hits:
peer = _match_neighbor_to_node( peer = _match_neighbor_to_node(
hit, nodes=nodes, nes=nes, self_node_id=n.id hit, nodes=nodes, nes=nes, umes=umes, self_node_id=n.id
) )
if peer is None: if peer is None:
continue continue
@ -469,9 +536,9 @@ def iter_discover_neighbors(
added += 1 added += 1
result = TopologyDiscoverNeResult( result = TopologyDiscoverNeResult(
ne_id=ne.id, ne_id=target["ne_id"],
ne_name=ne.name or "", ne_name=target["ne_name"],
ne_ip=ne.ip_address or "", ne_ip=target["ne_ip"],
ok=True, ok=True,
command=cmd, command=cmd,
neighbors=len(hits), neighbors=len(hits),

View file

@ -647,6 +647,8 @@ const en = {
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}", discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}", discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}",
discoverNeFail: "Collect failed", discoverNeFail: "Collect failed",
discoverNoTargets:
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
removeStale: "Clear missing ({{count}})", removeStale: "Clear missing ({{count}})",
removeStaleHint: "Delete red-marked missing edges", removeStaleHint: "Delete red-marked missing edges",
staleRemoved: "Removed {{count}} missing edge(s)", staleRemoved: "Removed {{count}} missing edge(s)",

View file

@ -645,6 +645,7 @@ const zh = {
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}", discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}", discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}",
discoverNeFail: "采集失败", discoverNeFail: "采集失败",
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
removeStale: "清除未发现 ({{count}})", removeStale: "清除未发现 ({{count}})",
removeStaleHint: "删除红色标记的未发现链路", removeStaleHint: "删除红色标记的未发现链路",
staleRemoved: "已删除 {{count}} 条未发现链路", staleRemoved: "已删除 {{count}} 条未发现链路",

View file

@ -995,15 +995,20 @@ export function TopologyPage() {
</div> </div>
{discovering ? ( {discovering ? (
<p className="panel__hint panel__hint--live"> <p className="panel__hint panel__hint--live">
{t("topology.discoverProgressLive") {discoverProgress.total <= 0
.replace("{{index}}", String(discoverProgress.index)) ? t("topology.discoverNoTargets")
.replace("{{total}}", String(discoverProgress.total)) : t("topology.discoverProgressLive")
.replace( .replace("{{index}}", String(discoverProgress.index))
"{{name}}", .replace("{{total}}", String(discoverProgress.total))
discoverProgress.neName || discoverProgress.neIp || "…", .replace(
)} "{{name}}",
discoverProgress.neName || discoverProgress.neIp || "…",
)}
</p> </p>
) : null} ) : null}
{!discovering && discoverReport && discoverReport.scanned === 0 ? (
<p className="panel__hint">{t("topology.discoverNoTargets")}</p>
) : null}
{discoverError ? <p className="topo-discover__error">{discoverError}</p> : null} {discoverError ? <p className="topo-discover__error">{discoverError}</p> : null}
{discoverResults.length > 0 || discoverReport ? ( {discoverResults.length > 0 || discoverReport ? (
<> <>