Add ZTE LLDP brief parsing and UME NEs to topology palette.

ZXROS uses show lldp neighbor brief; topology library can switch between managed and UME inventory.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-30 00:18:07 +08:00
parent d3fd840637
commit bfc10ee8cf
7 changed files with 359 additions and 44 deletions

View file

@ -91,8 +91,8 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
),
"zte": VendorLldpProfile(
key="zte",
lldp_command="show lldp neighbors",
notes="device_type zte_*; confirm ZXROS keyword on lab.",
lldp_command="show lldp neighbor brief",
notes="device_type zte_*; ZXROS brief table (Local Interface / Port ID / System Name).",
),
"juniper": VendorLldpProfile(
key="juniper",
@ -203,8 +203,10 @@ def parse_h3c_lldp(text: str) -> list[NeighborHit]:
def parse_zte_lldp(text: str) -> list[NeighborHit]:
"""ZTE ZXROS LLDP — placeholder; brief table may work as interim."""
# TODO: replace with ZXROS-specific parser using lab echo.
"""ZTE ZXROS `show lldp neighbor brief` table."""
hits = _parse_zte_lldp_brief(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
@ -338,6 +340,59 @@ def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]:
return hits
def _parse_zte_lldp_brief(text: str) -> list[NeighborHit]:
"""ZTE ZXROS `show lldp neighbor brief`.
Columns: Local Interface | Scope | Chassis ID | Port ID | Holdtime | System Name
Example:
cgei-1/1/0/34 NB 744a.a42d.8970 cgei-1/1/0/36 91 KND-VKAU-EN1-Z20HS
"""
raw = str(text or "")
if not re.search(r"(?i)Local\s+Interface", raw) or not re.search(r"(?i)System\s+Name", raw):
return []
# Scope codes seen on ZXROS: NB / NC / NTPMR (and possibly others).
row_re = re.compile(
r"^(?P<local>\S+)\s+"
r"(?P<scope>[A-Za-z]{2,8})\s+"
r"(?P<chassis>\S+)\s+"
r"(?P<port>\S+)\s+"
r"(?P<hold>\d+)\s+"
r"(?P<name>\S.*?)\s*$"
)
hits: list[NeighborHit] = []
for ln in raw.splitlines():
s = ln.strip()
if not s or set(s) <= {"-", "="}:
continue
low = s.lower()
if "local interface" in low or low.startswith(("total", "scope", "capability", "---")):
continue
if s.endswith("#") or "show lldp" in low:
continue
m = row_re.match(s)
if not m:
continue
scope = m.group("scope").upper()
# Reject rows that clearly aren't neighbor entries (e.g. mis-split header leftovers).
if scope in {"INTERFACE", "CHASSIS", "PORT", "HOLDTIME", "SYSTEM"}:
continue
name = (m.group("name") or "").strip()
local_port = (m.group("local") or "").strip()
remote_port = (m.group("port") or "").strip()
if not name and not remote_port and not local_port:
continue
hits.append(
NeighborHit(
remote_name=name,
local_port=local_port,
remote_port=remote_port,
protocol="lldp",
)
)
return hits
def _parse_lldp_brief_table(text: str) -> list[NeighborHit]:
"""Cisco/ZTE-style brief table: Device ID / Local Intf / ... / Port ID."""
lines = [ln.rstrip() for ln in str(text or "").splitlines()]

View file

@ -9,7 +9,7 @@ from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
from .ne_exec import execute_managed_ne_commands
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
from .topology_schemas import (
@ -113,10 +113,41 @@ def _ne_lookup(db: Session, ne_ids: set[str]) -> dict[str, ManagedNE]:
return {r.id: r for r in rows}
def _node_out(n: TopologyNode, ne: ManagedNE | None) -> TopologyNodeOut:
def _ume_lookup(db: Session, ume_ids: set[str]) -> dict[str, UmeInventoryNE]:
ids = {str(x).strip() for x in ume_ids if str(x).strip()}
if not ids:
return {}
rows = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id.in_(list(ids))).all()
return {str(r.ne_id): r for r in rows}
def _node_out(
n: TopologyNode,
ne: ManagedNE | None,
ume: UmeInventoryNE | None = None,
) -> TopologyNodeOut:
label = (n.label or "").strip()
if not label and ne is not None:
label = (ne.name or ne.ip_address or n.id)[:256]
ne_name = ""
ne_ip = ""
vendor = ""
protocol = ""
connect_status = ""
if ne is not None:
if not label:
label = (ne.name or ne.ip_address or n.id)[:256]
ne_name = ne.name or ""
ne_ip = ne.ip_address or ""
vendor = ne.vendor or ""
protocol = ne.protocol or ""
connect_status = ne.connect_status or ""
elif ume is not None:
ume_name = (ume.host_name or ume.ne_name or ume.user_label or "").strip()
if not label:
label = (ume_name or ume.ip_address or n.id)[:256]
ne_name = ume_name
ne_ip = ume.ip_address or ""
vendor = (ume.vendor or "ZTE").strip() or "ZTE"
connect_status = ume.connection_status or ""
return TopologyNodeOut(
id=n.id,
map_id=n.map_id,
@ -125,11 +156,11 @@ def _node_out(n: TopologyNode, ne: ManagedNE | None) -> TopologyNodeOut:
label=label,
x=float(n.x or 0),
y=float(n.y or 0),
ne_name=(ne.name if ne else ""),
ne_ip=(ne.ip_address if ne else ""),
vendor=(ne.vendor if ne else ""),
protocol=(ne.protocol if ne else ""),
connect_status=(ne.connect_status if ne else ""),
ne_name=ne_name,
ne_ip=ne_ip,
vendor=vendor,
protocol=protocol,
connect_status=connect_status,
)
@ -151,9 +182,17 @@ def get_graph(db: Session, map_id: str) -> TopologyGraphOut:
nodes = db.query(TopologyNode).filter(TopologyNode.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})
umes = _ume_lookup(db, {str(n.ume_ne_id or "") for n in nodes if n.ume_ne_id})
return TopologyGraphOut(
map=_map_out(row, node_count=len(nodes), edge_count=len(edges)),
nodes=[_node_out(n, nes.get(str(n.managed_ne_id or ""))) for n in nodes],
nodes=[
_node_out(
n,
nes.get(str(n.managed_ne_id or "")),
umes.get(str(n.ume_ne_id or "")),
)
for n in nodes
],
edges=[_edge_out(e) for e in edges],
)