Add topology maps with LLDP discovery and React Flow canvas.

Includes map CRUD, graph save, neighbor discover, and UI controls for labels, sidebar, and edge flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 23:53:06 +08:00
parent 3b8342acfb
commit dc805c2086
18 changed files with 3084 additions and 4 deletions

View file

@ -25,6 +25,7 @@ from .collection_router import router as collection_router
from .cli_router import router as cli_router
from .managed_ne_router import router as managed_ne_router
from .webcrt_router import router as webcrt_router
from .topology_router import router as topology_router
from .importer import aggregate_alarms, import_alarm_excel, query_alarms
from .models import (
AiAnalyzeHistory,
@ -125,6 +126,7 @@ app.include_router(managed_ne_router)
app.include_router(cli_router)
app.include_router(collection_router)
app.include_router(webcrt_router)
app.include_router(topology_router)
parser_cfg = load_parser_config()
_UME_CLIENT_SINGLETON = UMEClient(
token_loader=lambda: load_shared_token(),

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@ -384,3 +384,49 @@ class NeCollectionRun(Base):
output_rel_path: Mapped[str] = mapped_column(String(1024), default="")
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class TopologyMap(Base):
"""Named topology canvas (document-style graph)."""
__tablename__ = "topology_map"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
remark: Mapped[str] = mapped_column(String(1024), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class TopologyNode(Base):
"""Node on a topology map; preferably references an inventory NE."""
__tablename__ = "topology_node"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
map_id: Mapped[str] = mapped_column(String(64), index=True)
managed_ne_id: Mapped[str] = mapped_column(String(64), default="", index=True)
ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
label: Mapped[str] = mapped_column(String(256), default="")
x: Mapped[float] = mapped_column(Float, default=0.0)
y: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class TopologyEdge(Base):
"""Link between two topology nodes."""
__tablename__ = "topology_edge"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
map_id: Mapped[str] = mapped_column(String(64), index=True)
source_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="")
target_port: Mapped[str] = mapped_column(String(128), default="")
# manual | lldp | cdp
source: Mapped[str] = mapped_column(String(32), default="manual", index=True)
discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

497
netx_api/topology_lldp.py Normal file
View file

@ -0,0 +1,497 @@
"""LLDP/CDP neighbor command templates and output parsers (per vendor).
Multi-vendor fabrics default to LLDP. Resolve profile primarily from Netmiko
``device_type`` (managed NE / UME already store it), then fall back to vendor label.
Each vendor has:
- a show/display command
- a dedicated parse_* stub (fill with real lab echoes later)
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
@dataclass
class NeighborHit:
remote_name: str = ""
remote_ip: str = ""
local_port: str = ""
remote_port: str = ""
protocol: str = "lldp" # lldp | cdp
@dataclass(frozen=True)
class VendorLldpProfile:
"""One vendor's LLDP (and optional CDP) discovery profile."""
key: str
lldp_command: str
cdp_command: str = ""
notes: str = ""
ParserFn = Callable[[str], list[NeighborHit]]
_IPV4_RE = re.compile(
r"(?<![\d.])(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?![\d.])"
)
# device_type (Netmiko) -> profile key. Prefer inventory device_type over fuzzy text.
# Keep aligned with netx_api.device_types.SUPPORTED_DEVICE_TYPES families.
_DEVICE_TYPE_PREFIX_RULES: tuple[tuple[str, str], ...] = (
("cisco_", "cisco"),
("huawei", "huawei"), # huawei, huawei_vrp, huawei_olt, ...
("zte_", "zte"),
("juniper", "juniper"), # juniper, juniper_junos, ...
("nokia_", "nokia"),
("alcatel_sros", "nokia"),
("alcatel_", "nokia"),
("ericsson_", "ericsson"),
("hp_comware", "h3c"),
("h3c_", "h3c"),
)
_VENDOR_LABEL_TO_KEY: dict[str, str] = {
"cisco": "cisco",
"huawei": "huawei",
"h3c": "h3c",
"zte": "zte",
"juniper": "juniper",
"nokia": "nokia",
"ericsson": "ericsson",
"alcatel": "nokia",
"alcatel-lucent": "nokia",
}
# ---------------------------------------------------------------------------
# Vendor registry — command templates (edit / refine with lab echoes)
# ---------------------------------------------------------------------------
VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
"cisco": VendorLldpProfile(
key="cisco",
lldp_command="show lldp neighbors detail",
cdp_command="show cdp neighbors detail",
notes="device_type cisco_*; use detail form for System Name / Port id.",
),
"huawei": VendorLldpProfile(
key="huawei",
lldp_command="display lldp neighbor",
notes="device_type huawei*; per-interface neighbor blocks.",
),
"h3c": VendorLldpProfile(
key="h3c",
lldp_command="display lldp neighbor-information list",
notes="Placeholder Comware command; confirm on lab.",
),
"zte": VendorLldpProfile(
key="zte",
lldp_command="show lldp neighbors",
notes="device_type zte_*; confirm ZXROS keyword on lab.",
),
"juniper": VendorLldpProfile(
key="juniper",
lldp_command="show lldp neighbors",
notes="device_type juniper*; detail form TBD.",
),
"nokia": VendorLldpProfile(
key="nokia",
lldp_command="show system lldp neighbor",
notes="device_type nokia_* / alcatel_*; SRL may differ.",
),
"ericsson": VendorLldpProfile(
key="ericsson",
lldp_command="show lldp neighbors",
notes="device_type ericsson_*; confirm IPOS/SEOS on lab.",
),
"generic": VendorLldpProfile(
key="generic",
lldp_command="show lldp neighbors",
notes="Fallback when device_type/vendor unknown.",
),
}
def resolve_vendor_key(vendor: str = "", device_type: str = "") -> str:
"""Map inventory device_type (preferred) or vendor label -> profile key."""
dtype = str(device_type or "").strip().lower()
if dtype:
for prefix, key in _DEVICE_TYPE_PREFIX_RULES:
if dtype == prefix.rstrip("_") or dtype.startswith(prefix):
return key
if dtype in VENDOR_LLDP_PROFILES and dtype != "generic":
return dtype
label = str(vendor or "").strip().lower()
if label:
if label in _VENDOR_LABEL_TO_KEY:
return _VENDOR_LABEL_TO_KEY[label]
for token, key in _VENDOR_LABEL_TO_KEY.items():
if label == token or label.startswith(f"{token} ") or label.startswith(f"{token}-"):
return key
return "generic"
def get_vendor_profile(vendor: str = "", device_type: str = "") -> VendorLldpProfile:
key = resolve_vendor_key(vendor, device_type)
return VENDOR_LLDP_PROFILES.get(key) or VENDOR_LLDP_PROFILES["generic"]
def lldp_command_for_vendor(vendor: str = "", device_type: str = "") -> str:
return get_vendor_profile(vendor, device_type).lldp_command
def cdp_command_for_vendor(vendor: str = "", device_type: str = "") -> str:
return get_vendor_profile(vendor, device_type).cdp_command
def pick_neighbor_command(
*,
protocol: str = "auto",
vendor: str = "",
device_type: str = "",
) -> tuple[str, str]:
"""Return (command, protocol_tag).
Default/auto uses LLDP for all vendors (multi-vendor fabrics).
Pass protocol=\"cdp\" only when explicitly requesting CDP (Cisco).
"""
proto = str(protocol or "auto").strip().lower()
profile = get_vendor_profile(vendor, device_type)
if proto == "cdp":
cmd = profile.cdp_command or "show cdp neighbors detail"
return cmd, "cdp"
return profile.lldp_command, "lldp"
# ---------------------------------------------------------------------------
# Parsers — keep working ones; stubs return [] until lab echoes are added
# ---------------------------------------------------------------------------
def parse_cisco_lldp(text: str) -> list[NeighborHit]:
"""Cisco `show lldp neighbors detail` (preferred); brief table as fallback."""
hits = _parse_cisco_lldp_detail(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
def parse_cisco_cdp(text: str) -> list[NeighborHit]:
"""Cisco `show cdp neighbors detail`."""
return _parse_cdp_detail(text)
def parse_huawei_lldp(text: str) -> list[NeighborHit]:
"""Huawei `display lldp neighbor`."""
hits = _parse_huawei_lldp_neighbor(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
def parse_h3c_lldp(text: str) -> list[NeighborHit]:
"""H3C Comware LLDP — placeholder until lab echo is captured."""
# TODO: replace with Comware-specific parser using real `display lldp ...` output.
_ = text
return []
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.
return _parse_lldp_brief_table(text)
def parse_juniper_lldp(text: str) -> list[NeighborHit]:
"""Juniper Junos LLDP — placeholder until lab echo is captured."""
# TODO: parse `show lldp neighbors` / detail from Junos sample.
_ = text
return []
def parse_nokia_lldp(text: str) -> list[NeighborHit]:
"""Nokia SROS/SRL LLDP — placeholder until lab echo is captured."""
# TODO: parse `show system lldp neighbor` (SROS) / SRL equivalent.
_ = text
return []
def parse_ericsson_lldp(text: str) -> list[NeighborHit]:
"""Ericsson IPOS/SEOS LLDP — placeholder until lab echo is captured."""
# TODO: parse vendor show output from lab.
_ = text
return []
def parse_generic_lldp(text: str) -> list[NeighborHit]:
"""Best-effort fallback when vendor is unknown."""
hits = _parse_cisco_lldp_detail(text)
if hits:
return hits
hits = _parse_huawei_lldp_neighbor(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
_VENDOR_PARSERS: dict[str, ParserFn] = {
"cisco": parse_cisco_lldp,
"huawei": parse_huawei_lldp,
"h3c": parse_h3c_lldp,
"zte": parse_zte_lldp,
"juniper": parse_juniper_lldp,
"nokia": parse_nokia_lldp,
"ericsson": parse_ericsson_lldp,
"generic": parse_generic_lldp,
}
def parse_neighbor_output(
text: str,
*,
protocol: str = "lldp",
vendor: str = "",
device_type: str = "",
) -> list[NeighborHit]:
"""Parse neighbor CLI output using the device_type/vendor-specific parser."""
raw = str(text or "")
if not raw.strip():
return []
proto = str(protocol or "lldp").strip().lower()
key = resolve_vendor_key(vendor, device_type)
if proto == "cdp":
return parse_cisco_cdp(raw)
parser = _VENDOR_PARSERS.get(key) or parse_generic_lldp
hits = parser(raw)
if hits:
return hits
# Soft fallbacks so an early/wrong tag still yields something useful.
if key != "cisco":
hits = parse_cisco_lldp(raw)
if hits:
return hits
if key != "huawei":
hits = parse_huawei_lldp(raw)
if hits:
return hits
return []
# ---------------------------------------------------------------------------
# Shared low-level helpers
# ---------------------------------------------------------------------------
def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]:
"""Cisco IOS `show lldp neighbors detail` blocks starting at Local Intf."""
raw = str(text or "")
if not re.search(r"(?i)Local\s+Intf\s*:", raw):
return []
chunks = re.split(r"(?i)(?=Local\s+Intf\s*:)", raw)
hits: list[NeighborHit] = []
for chunk in chunks:
if not re.search(r"(?i)Local\s+Intf\s*:", chunk):
continue
local_port = _kv(chunk, r"Local\s+Intf\s*:\s*(.+)")
remote_port = _kv(chunk, r"Port\s+id\s*:\s*(.+)")
sys_name = _kv(chunk, r"System\s+Name\s*:\s*(.+)")
# Prefer IPv4 under Management Addresses; skip OID / MAC "Other:" lines.
ip = ""
m = re.search(
r"(?is)Management\s+Addresses?\s*:(.*?)(?:\n\s*\n|Auto Negotiation|Total entries|$)",
chunk,
)
if m:
mgmt_lines = []
for ln in (m.group(1) or "").splitlines():
low = ln.lower()
if "oid" in low or re.search(r"(?i)^\s*other\s*:", ln):
continue
mgmt_lines.append(ln)
ip_m = _IPV4_RE.search("\n".join(mgmt_lines))
if ip_m:
ip = ip_m.group(0)
if not sys_name and not remote_port and not local_port:
continue
# Skip empty / not-advertised system names
name = (sys_name or "").strip()
if name.lower() in {"", "-", "not advertised"}:
name = ""
hits.append(
NeighborHit(
remote_name=name,
remote_ip=ip,
local_port=(local_port or "").strip(),
remote_port=(remote_port or "").strip(),
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()]
start = -1
for i, ln in enumerate(lines):
low = ln.lower()
if "device id" in low and ("local" in low or "intf" in low or "port" in low):
start = i + 1
break
if "system name" in low and "local" in low:
start = i + 1
break
if start < 0:
return []
hits: list[NeighborHit] = []
for ln in lines[start:]:
s = ln.strip()
if not s or set(s) <= {"-", "="}:
continue
if s.lower().startswith(("total", "capability", "---")):
continue
parts = s.split()
if len(parts) < 2:
continue
remote = parts[0]
local_port = parts[1] if len(parts) >= 2 else ""
remote_port = parts[-1] if len(parts) >= 4 else ""
if remote.lower() in {"device", "system", "chassis"}:
continue
hits.append(
NeighborHit(
remote_name=remote,
local_port=local_port,
remote_port=remote_port,
protocol="lldp",
)
)
return hits
def _parse_huawei_lldp_neighbor(text: str) -> list[NeighborHit]:
"""Huawei VRP `display lldp neighbor` — per-interface sections."""
raw = str(text or "")
# Split on "<ifname> has N neighbor(s):"
header_re = re.compile(
r"(?im)^(\S+)\s+has\s+(\d+)\s+neighbor\(s\)\s*:\s*$"
)
hits: list[NeighborHit] = []
matches = list(header_re.finditer(raw))
if not matches:
# Older compact sample with Local Interface: field
return _parse_huawei_lldp_blocks_legacy(raw)
for i, m in enumerate(matches):
local_if = m.group(1).strip()
count = int(m.group(2))
if count <= 0:
continue
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw)
section = raw[start:end]
# One section may contain multiple neighbors; split on Neighbor index
sub_chunks = re.split(r"(?im)(?=^Neighbor\s+index\s*:)", section)
for chunk in sub_chunks:
if not re.search(r"(?i)Neighbor\s+index\s*:", chunk):
# Sometimes fields appear without explicit index; still try once.
if not re.search(r"(?i)System\s+name\s*:", chunk):
continue
sys_name = _kv(chunk, r"System\s+name\s*:\s*(.+)")
port_id = _kv(chunk, r"Port\s+ID\s*:\s*(.+)")
mgmt = _kv(chunk, r"Management\s+address\s*:\s*(.+)")
ip = ""
if mgmt:
ip_m = _IPV4_RE.search(mgmt)
if ip_m:
ip = ip_m.group(0)
name = (sys_name or "").strip()
# Hostname may be FQDN — keep as-is; matcher strips domain.
if not name and not port_id and not ip:
continue
hits.append(
NeighborHit(
remote_name=name,
remote_ip=ip,
local_port=local_if,
remote_port=(port_id or "").strip(),
protocol="lldp",
)
)
return hits
def _parse_huawei_lldp_blocks_legacy(text: str) -> list[NeighborHit]:
"""Older/compact Huawei block with Local Interface field."""
hits: list[NeighborHit] = []
blocks = re.split(r"\n\s*\n", str(text or ""))
for block in blocks:
if not block.strip():
continue
sys_name = _kv(block, r"System\s+name\s*[:=]\s*(.+)")
local_if = _kv(block, r"Local\s+(?:Interface|Port)\s*[:=]\s*(.+)")
port_id = _kv(block, r"Port\s+ID\s*[:=]\s*(.+)")
mgmt = _kv(block, r"Management\s+address\s*[:=]\s*(.+)")
if not sys_name and not port_id:
continue
ip = ""
if mgmt:
m = _IPV4_RE.search(mgmt)
if m:
ip = m.group(0)
hits.append(
NeighborHit(
remote_name=(sys_name or "").strip(),
remote_ip=ip,
local_port=(local_if or "").strip(),
remote_port=(port_id or "").strip(),
protocol="lldp",
)
)
return hits
def _parse_cdp_detail(text: str) -> list[NeighborHit]:
"""Cisco `show cdp neighbors detail`."""
hits: list[NeighborHit] = []
chunks = re.split(r"(?i)\n(?=Device ID\s*:)", str(text or ""))
for chunk in chunks:
if not re.search(r"(?i)Device\s+ID\s*:", chunk):
continue
device_id = _kv(chunk, r"Device\s+ID\s*:\s*(.+)")
ip = ""
ip_line = _kv(chunk, r"IP(?:v4)?\s+address\s*:\s*(.+)")
if ip_line:
m = _IPV4_RE.search(ip_line)
if m:
ip = m.group(0)
local_port = _kv(chunk, r"Interface\s*:\s*([^,\n]+)")
remote_port = _kv(chunk, r"Port ID\s*(?:\(outgoing port\))?\s*:\s*(.+)")
if not device_id and not ip:
continue
hits.append(
NeighborHit(
remote_name=(device_id or "").strip(),
remote_ip=ip,
local_port=(local_port or "").strip().rstrip(","),
remote_port=(remote_port or "").strip(),
protocol="cdp",
)
)
return hits
def _kv(text: str, pattern: str) -> str:
m = re.search(pattern, text, flags=re.IGNORECASE | re.MULTILINE)
if not m:
return ""
return str(m.group(1) or "").strip()

View file

@ -0,0 +1,71 @@
"""Topology HTTP routes."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .db import get_db
from .topology_schemas import (
TopologyDiscoverRequest,
TopologyGraphPut,
TopologyMapCreate,
TopologyMapUpdate,
)
from .topology_service import (
create_map,
delete_map,
discover_neighbors,
get_graph,
list_maps,
put_graph,
update_map,
)
router = APIRouter(prefix="/v1/topology", tags=["topology"])
@router.get("/maps")
def api_list_maps(db: Session = Depends(get_db)) -> dict[str, Any]:
return list_maps(db)
@router.post("/maps")
def api_create_map(body: TopologyMapCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
return create_map(db, body).model_dump()
@router.get("/maps/{map_id}")
def api_get_map(map_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return get_graph(db, map_id).model_dump()
@router.patch("/maps/{map_id}")
def api_patch_map(
map_id: str, body: TopologyMapUpdate, db: Session = Depends(get_db)
) -> dict[str, Any]:
return update_map(db, map_id, body).model_dump()
@router.delete("/maps/{map_id}")
def api_delete_map(map_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return delete_map(db, map_id)
@router.put("/maps/{map_id}/graph")
def api_put_graph(
map_id: str, body: TopologyGraphPut, db: Session = Depends(get_db)
) -> dict[str, Any]:
return put_graph(db, map_id, body).model_dump()
@router.post("/maps/{map_id}/discover")
def api_discover(
map_id: str,
body: TopologyDiscoverRequest | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
req = body or TopologyDiscoverRequest()
return discover_neighbors(db, map_id, req).model_dump()

View file

@ -0,0 +1,112 @@
"""Pydantic schemas for topology maps / nodes / edges."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class TopologyMapCreate(BaseModel):
name: str = Field(min_length=1, max_length=256)
remark: str = Field(default="", max_length=1024)
class TopologyMapUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=256)
remark: str | None = Field(default=None, max_length=1024)
class TopologyMapOut(BaseModel):
id: str
name: str
remark: str
node_count: int = 0
edge_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class TopologyNodeIn(BaseModel):
id: str = Field(min_length=1, max_length=64)
managed_ne_id: str = ""
ume_ne_id: str = ""
label: str = ""
x: float = 0.0
y: float = 0.0
class TopologyEdgeIn(BaseModel):
id: str = Field(min_length=1, max_length=64)
source_node_id: str = Field(min_length=1, max_length=64)
target_node_id: str = Field(min_length=1, max_length=64)
source_port: str = ""
target_port: str = ""
source: str = "manual"
class TopologyNodeOut(BaseModel):
id: str
map_id: str
managed_ne_id: str = ""
ume_ne_id: str = ""
label: str = ""
x: float = 0.0
y: float = 0.0
ne_name: str = ""
ne_ip: str = ""
vendor: str = ""
protocol: str = ""
connect_status: str = ""
class TopologyEdgeOut(BaseModel):
id: str
map_id: str
source_node_id: str
target_node_id: str
source_port: str = ""
target_port: str = ""
source: str = "manual"
discovered_at: datetime | None = None
class TopologyGraphOut(BaseModel):
map: TopologyMapOut
nodes: list[TopologyNodeOut]
edges: list[TopologyEdgeOut]
class TopologyGraphPut(BaseModel):
nodes: list[TopologyNodeIn] = Field(default_factory=list)
edges: list[TopologyEdgeIn] = Field(default_factory=list)
class TopologyDiscoverRequest(BaseModel):
"""Run LLDP/CDP discovery for managed NEs currently on the map."""
protocol: str = Field(default="auto", description="auto | lldp | cdp")
ne_ids: list[str] | None = None
class TopologyDiscoverNeResult(BaseModel):
ne_id: str
ne_name: str = ""
ne_ip: str = ""
ok: bool = False
command: str = ""
neighbors: int = 0
edges_added: int = 0
edges_updated: int = 0
error: str = ""
raw_preview: str = ""
class TopologyDiscoverOut(BaseModel):
map_id: str
protocol: str
scanned: int = 0
edges_added: int = 0
edges_updated: int = 0
results: list[TopologyDiscoverNeResult] = Field(default_factory=list)
graph: TopologyGraphOut | None = None

View file

@ -0,0 +1,420 @@
"""Topology map CRUD, graph save, and LLDP/CDP edge discovery."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode
from .ne_exec import execute_managed_ne_commands
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
from .topology_schemas import (
TopologyDiscoverNeResult,
TopologyDiscoverOut,
TopologyDiscoverRequest,
TopologyEdgeIn,
TopologyEdgeOut,
TopologyGraphOut,
TopologyGraphPut,
TopologyMapCreate,
TopologyMapOut,
TopologyMapUpdate,
TopologyNodeIn,
TopologyNodeOut,
)
def _utcnow() -> datetime:
return datetime.utcnow()
def _map_out(m: TopologyMap, *, node_count: int = 0, edge_count: int = 0) -> TopologyMapOut:
return TopologyMapOut(
id=m.id,
name=m.name,
remark=m.remark or "",
node_count=node_count,
edge_count=edge_count,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _get_map_or_404(db: Session, map_id: str) -> TopologyMap:
mid = str(map_id or "").strip()
row = db.get(TopologyMap, mid) if mid else None
if row is None:
raise HTTPException(status_code=404, detail="topology_map_not_found")
return row
def list_maps(db: Session) -> dict[str, Any]:
rows = db.query(TopologyMap).order_by(TopologyMap.updated_at.desc()).all()
items: list[TopologyMapOut] = []
for m in rows:
nc = db.query(TopologyNode).filter(TopologyNode.map_id == m.id).count()
ec = db.query(TopologyEdge).filter(TopologyEdge.map_id == m.id).count()
items.append(_map_out(m, node_count=nc, edge_count=ec))
return {"total": len(items), "items": [i.model_dump() for i in items]}
def create_map(db: Session, body: TopologyMapCreate) -> TopologyMapOut:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
now = _utcnow()
row = TopologyMap(
id=uuid4().hex,
name=name[:256],
remark=str(body.remark or "")[:1024],
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return _map_out(row)
def update_map(db: Session, map_id: str, body: TopologyMapUpdate) -> TopologyMapOut:
row = _get_map_or_404(db, map_id)
if body.name is not None:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
row.name = name[:256]
if body.remark is not None:
row.remark = str(body.remark or "")[:1024]
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
nc = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).count()
ec = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).count()
return _map_out(row, node_count=nc, edge_count=ec)
def delete_map(db: Session, map_id: str) -> dict[str, Any]:
row = _get_map_or_404(db, map_id)
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
db.delete(row)
db.commit()
return {"ok": True, "map_id": map_id, "deleted": True}
def _ne_lookup(db: Session, ne_ids: set[str]) -> dict[str, ManagedNE]:
if not ne_ids:
return {}
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(list(ne_ids))).all()
return {r.id: r for r in rows}
def _node_out(n: TopologyNode, ne: ManagedNE | 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]
return TopologyNodeOut(
id=n.id,
map_id=n.map_id,
managed_ne_id=n.managed_ne_id or "",
ume_ne_id=n.ume_ne_id or "",
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 ""),
)
def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
return TopologyEdgeOut(
id=e.id,
map_id=e.map_id,
source_node_id=e.source_node_id,
target_node_id=e.target_node_id,
source_port=e.source_port or "",
target_port=e.target_port or "",
source=e.source or "manual",
discovered_at=e.discovered_at,
)
def get_graph(db: Session, map_id: str) -> TopologyGraphOut:
row = _get_map_or_404(db, map_id)
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})
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],
edges=[_edge_out(e) for e in edges],
)
def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraphOut:
row = _get_map_or_404(db, map_id)
nodes_in = list(body.nodes or [])
edges_in = list(body.edges or [])
if len(nodes_in) > 2000:
raise HTTPException(status_code=400, detail="too_many_nodes")
if len(edges_in) > 5000:
raise HTTPException(status_code=400, detail="too_many_edges")
node_ids = set()
for n in nodes_in:
nid = str(n.id or "").strip()
if not nid:
raise HTTPException(status_code=400, detail="node_id_required")
if nid in node_ids:
raise HTTPException(status_code=400, detail=f"duplicate_node_id:{nid}")
node_ids.add(nid)
for e in edges_in:
sid = str(e.source_node_id or "").strip()
tid = str(e.target_node_id or "").strip()
if sid not in node_ids or tid not in node_ids:
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp"}:
raise HTTPException(status_code=400, detail="invalid_edge_source")
now = _utcnow()
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
for n in nodes_in:
db.add(
TopologyNode(
id=str(n.id).strip(),
map_id=row.id,
managed_ne_id=str(n.managed_ne_id or "").strip(),
ume_ne_id=str(n.ume_ne_id or "").strip(),
label=str(n.label or "").strip()[:256],
x=float(n.x or 0),
y=float(n.y or 0),
created_at=now,
updated_at=now,
)
)
for e in edges_in:
db.add(
TopologyEdge(
id=str(e.id).strip() or uuid4().hex,
map_id=row.id,
source_node_id=str(e.source_node_id).strip(),
target_node_id=str(e.target_node_id).strip(),
source_port=str(e.source_port or "").strip()[:128],
target_port=str(e.target_port or "").strip()[:128],
source=str(e.source or "manual").strip().lower() or "manual",
discovered_at=now if str(e.source or "").lower() in {"lldp", "cdp"} else None,
created_at=now,
updated_at=now,
)
)
row.updated_at = now
db.commit()
return get_graph(db, row.id)
def _norm_key(s: str) -> str:
return re_sub_host(str(s or "").strip().lower())
def re_sub_host(s: str) -> str:
# Strip domain / trailing punctuation for hostname matching.
t = s.split(".")[0].strip().lower()
return t.rstrip(".,;:")
def _match_neighbor_to_node(
hit: NeighborHit,
*,
nodes: list[TopologyNode],
nes: dict[str, ManagedNE],
self_node_id: str,
) -> TopologyNode | None:
name_key = _norm_key(hit.remote_name)
ip_key = str(hit.remote_ip or "").strip()
for n in nodes:
if n.id == self_node_id:
continue
ne = nes.get(str(n.managed_ne_id or ""))
candidates = [
_norm_key(n.label or ""),
_norm_key(ne.name if ne else ""),
str(ne.ip_address if ne else "").strip(),
]
if ip_key and ip_key in candidates:
return n
if name_key and name_key in {_norm_key(c) for c in candidates if c}:
return n
# Also match managed NE name without case
if ne and name_key and name_key == _norm_key(ne.name):
return n
return None
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
if a <= b:
return (a, b, local_port, remote_port)
return (b, a, remote_port, local_port)
def discover_neighbors(
db: Session,
map_id: str,
body: TopologyDiscoverRequest,
) -> TopologyDiscoverOut:
row = _get_map_or_404(db, map_id)
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})
filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()}
scan_nodes = [
n
for n in nodes
if n.managed_ne_id
and n.managed_ne_id in nes
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] = {}
for e in edges:
key = _edge_pair_key(
e.source_node_id,
e.target_node_id,
(e.source_port or "").strip(),
(e.target_port or "").strip(),
)
existing[key] = e
results: list[TopologyDiscoverNeResult] = []
added = 0
updated = 0
now = _utcnow()
proto_req = str(body.protocol or "auto").strip().lower() or "auto"
for n in scan_nodes:
ne = nes.get(n.managed_ne_id)
if ne is None:
continue
cmd, proto_tag = pick_neighbor_command(
protocol=proto_req,
vendor=ne.vendor or "",
device_type=ne.device_type or "",
)
if not cmd:
results.append(
TopologyDiscoverNeResult(
ne_id=ne.id,
ne_name=ne.name or "",
ne_ip=ne.ip_address or "",
ok=False,
error="no_command_for_vendor",
)
)
continue
exec_out = execute_managed_ne_commands(
db,
[cmd],
ne_id=ne.id,
read_timeout_sec=60,
)
if not exec_out.get("ok"):
results.append(
TopologyDiscoverNeResult(
ne_id=ne.id,
ne_name=ne.name or "",
ne_ip=ne.ip_address or "",
ok=False,
command=cmd,
error=str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
)
)
continue
raw = str(exec_out.get("output") or "")
hits = parse_neighbor_output(
raw,
protocol=proto_tag,
vendor=ne.vendor or "",
device_type=ne.device_type or "",
)
ne_added = 0
ne_updated = 0
for hit in hits:
peer = _match_neighbor_to_node(
hit, nodes=nodes, nes=nes, self_node_id=n.id
)
if peer is None:
continue
local_port = (hit.local_port or "").strip()[:128]
remote_port = (hit.remote_port or "").strip()[:128]
key = _edge_pair_key(n.id, peer.id, local_port, remote_port)
edge_proto = hit.protocol if hit.protocol in {"lldp", "cdp"} else proto_tag
cur = existing.get(key)
if cur is not None:
# Never downgrade manual edges; refresh discovery metadata only for discovered.
if (cur.source or "manual") == "manual":
continue
cur.source = edge_proto
cur.source_port = local_port if cur.source_node_id == n.id else remote_port
cur.target_port = remote_port if cur.source_node_id == n.id else local_port
cur.discovered_at = now
cur.updated_at = now
ne_updated += 1
updated += 1
continue
# Prefer orientation: scanning node as source.
new_edge = TopologyEdge(
id=uuid4().hex,
map_id=row.id,
source_node_id=n.id,
target_node_id=peer.id,
source_port=local_port,
target_port=remote_port,
source=edge_proto,
discovered_at=now,
created_at=now,
updated_at=now,
)
db.add(new_edge)
existing[key] = new_edge
ne_added += 1
added += 1
results.append(
TopologyDiscoverNeResult(
ne_id=ne.id,
ne_name=ne.name or "",
ne_ip=ne.ip_address or "",
ok=True,
command=cmd,
neighbors=len(hits),
edges_added=ne_added,
edges_updated=ne_updated,
raw_preview=raw[:800],
)
)
row.updated_at = now
db.commit()
graph = get_graph(db, row.id)
return TopologyDiscoverOut(
map_id=row.id,
protocol=proto_req,
scanned=len(results),
edges_added=added,
edges_updated=updated,
results=results,
graph=graph,
)