mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 16:50:47 +08:00
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:
parent
3b8342acfb
commit
dc805c2086
18 changed files with 3084 additions and 4 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
497
netx_api/topology_lldp.py
Normal 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()
|
||||
71
netx_api/topology_router.py
Normal file
71
netx_api/topology_router.py
Normal 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()
|
||||
112
netx_api/topology_schemas.py
Normal file
112
netx_api/topology_schemas.py
Normal 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
|
||||
420
netx_api/topology_service.py
Normal file
420
netx_api/topology_service.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue