mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 23:23:21 +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 .cli_router import router as cli_router
|
||||||
from .managed_ne_router import router as managed_ne_router
|
from .managed_ne_router import router as managed_ne_router
|
||||||
from .webcrt_router import router as webcrt_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 .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
||||||
from .models import (
|
from .models import (
|
||||||
AiAnalyzeHistory,
|
AiAnalyzeHistory,
|
||||||
|
|
@ -125,6 +126,7 @@ app.include_router(managed_ne_router)
|
||||||
app.include_router(cli_router)
|
app.include_router(cli_router)
|
||||||
app.include_router(collection_router)
|
app.include_router(collection_router)
|
||||||
app.include_router(webcrt_router)
|
app.include_router(webcrt_router)
|
||||||
|
app.include_router(topology_router)
|
||||||
parser_cfg = load_parser_config()
|
parser_cfg = load_parser_config()
|
||||||
_UME_CLIENT_SINGLETON = UMEClient(
|
_UME_CLIENT_SINGLETON = UMEClient(
|
||||||
token_loader=lambda: load_shared_token(),
|
token_loader=lambda: load_shared_token(),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
|
@ -384,3 +384,49 @@ class NeCollectionRun(Base):
|
||||||
output_rel_path: Mapped[str] = mapped_column(String(1024), default="")
|
output_rel_path: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
ended_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,
|
||||||
|
)
|
||||||
364
tests/test_topology.py
Normal file
364
tests/test_topology.py
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
"""Unit tests for topology CRUD and LLDP/CDP parsers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from netx_api import topology_lldp as lldp
|
||||||
|
from netx_api import topology_service as svc
|
||||||
|
from netx_api.db import Base, SessionLocal, engine
|
||||||
|
from netx_api.models import ManagedNE, TopologyMap
|
||||||
|
from netx_api.topology_schemas import (
|
||||||
|
TopologyDiscoverRequest,
|
||||||
|
TopologyEdgeIn,
|
||||||
|
TopologyGraphPut,
|
||||||
|
TopologyMapCreate,
|
||||||
|
TopologyNodeIn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CISCO_LLDP_BRIEF = """
|
||||||
|
Capability codes:
|
||||||
|
(R) Router, (B) Bridge
|
||||||
|
|
||||||
|
Device ID Local Intf Hold-time Capability Port ID
|
||||||
|
R1 Gi0/0 120 R Gi0/1
|
||||||
|
R3 Gi0/1 120 R Gi0/0
|
||||||
|
"""
|
||||||
|
|
||||||
|
CISCO_LLDP_DETAIL = """
|
||||||
|
R2#show lldp neighbors detail
|
||||||
|
------------------------------------------------
|
||||||
|
Local Intf: Gi0/1
|
||||||
|
Chassis id: 707b.5c6e.d130
|
||||||
|
Port id: Ethernet1/0/1
|
||||||
|
Port Description - not advertised
|
||||||
|
System Name: r1
|
||||||
|
|
||||||
|
System Description:
|
||||||
|
Huawei Versatile Routing Platform Software
|
||||||
|
VRP (R) software, Version 8.180 (NE40E V800R011C00SPC607B607)
|
||||||
|
Copyright (C) 2012-2018 Huawei Technologies Co., Ltd.
|
||||||
|
HUAWEI NE40E
|
||||||
|
|
||||||
|
|
||||||
|
Time remaining: 97 seconds
|
||||||
|
System Capabilities: B,R
|
||||||
|
Enabled Capabilities: B,R
|
||||||
|
Management Addresses:
|
||||||
|
Other: 70 7B 5C 6E FF 30 00
|
||||||
|
OID:
|
||||||
|
0.6.8.43.6.1.2.1.17.1.1.
|
||||||
|
Auto Negotiation - supported, enabled
|
||||||
|
Physical media capabilities - not advertised
|
||||||
|
Media Attachment Unit type - not advertised
|
||||||
|
Vlan ID: - not advertised
|
||||||
|
|
||||||
|
|
||||||
|
Total entries displayed: 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
CISCO_CDP_DETAIL = """
|
||||||
|
-------------------------
|
||||||
|
Device ID: R1.lab.local
|
||||||
|
IP address: 192.168.0.1
|
||||||
|
Platform: Cisco, Capabilities: Router
|
||||||
|
Interface: GigabitEthernet0/0, Port ID (outgoing port): GigabitEthernet0/1
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
Device ID: R3
|
||||||
|
IP address: 192.168.0.3
|
||||||
|
Interface: GigabitEthernet0/1, Port ID (outgoing port): GigabitEthernet0/0
|
||||||
|
"""
|
||||||
|
|
||||||
|
HUAWEI_LLDP = """
|
||||||
|
GigabitEthernet0/0/1 has 1 neighbor(s):
|
||||||
|
|
||||||
|
Neighbor index : 1
|
||||||
|
Chassis ID : 00e0-fc12-3456
|
||||||
|
Port ID : GigabitEthernet0/0/2
|
||||||
|
System name : r1
|
||||||
|
Management address : 192.168.0.127
|
||||||
|
Local Interface: GigabitEthernet0/0/1
|
||||||
|
"""
|
||||||
|
|
||||||
|
HUAWEI_LLDP_LAB = """
|
||||||
|
]display lldp neighbor
|
||||||
|
Ethernet1/0/0 has 0 neighbor(s)
|
||||||
|
|
||||||
|
Ethernet1/0/1 has 1 neighbor(s):
|
||||||
|
|
||||||
|
Neighbor index :1
|
||||||
|
Chassis type :macAddress
|
||||||
|
Chassis ID :5000-0003-0000
|
||||||
|
Port ID type :interfaceName
|
||||||
|
Port ID :Gi0/1
|
||||||
|
Port description :GigabitEthernet0/1
|
||||||
|
System name :R2.example.com
|
||||||
|
System description :Cisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.9(3)M4, RELEASE SOFTWARE (fc3)
|
||||||
|
Technical Support: http://www.cisco.com/techsupport
|
||||||
|
Copyright (c) 1986-2021 by Cisco Systems, Inc.
|
||||||
|
Compiled Wed 04-Aug-21 08:13 by mcpre
|
||||||
|
System capabilities supported :bridge router
|
||||||
|
System capabilities enabled :router
|
||||||
|
Management address type :ipv4
|
||||||
|
Management address :192.168.0.128
|
||||||
|
Expired time :110s
|
||||||
|
|
||||||
|
Port VLAN ID(PVID) :--
|
||||||
|
Discovered time :2026-06-05 17:07:21
|
||||||
|
|
||||||
|
Ethernet1/0/2 has 0 neighbor(s)
|
||||||
|
|
||||||
|
GigabitEthernet0/0/0 has 0 neighbor(s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyLldpParseTests(unittest.TestCase):
|
||||||
|
def test_parse_cisco_lldp_brief_fallback(self) -> None:
|
||||||
|
hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF)
|
||||||
|
self.assertGreaterEqual(len(hits), 2)
|
||||||
|
self.assertEqual(hits[0].remote_name, "R1")
|
||||||
|
self.assertEqual(hits[0].local_port, "Gi0/0")
|
||||||
|
|
||||||
|
def test_parse_cisco_lldp_detail_lab(self) -> None:
|
||||||
|
hits = lldp.parse_neighbor_output(
|
||||||
|
CISCO_LLDP_DETAIL, protocol="lldp", vendor="Cisco", device_type="cisco_ios"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(hits), 1)
|
||||||
|
self.assertEqual(hits[0].remote_name, "r1")
|
||||||
|
self.assertEqual(hits[0].local_port, "Gi0/1")
|
||||||
|
self.assertEqual(hits[0].remote_port, "Ethernet1/0/1")
|
||||||
|
self.assertEqual(hits[0].remote_ip, "") # Other: MAC only in this sample
|
||||||
|
|
||||||
|
def test_parse_cdp_detail(self) -> None:
|
||||||
|
hits = lldp.parse_neighbor_output(CISCO_CDP_DETAIL, protocol="cdp")
|
||||||
|
self.assertEqual(len(hits), 2)
|
||||||
|
self.assertEqual(hits[0].remote_ip, "192.168.0.1")
|
||||||
|
self.assertIn("GigabitEthernet0/0", hits[0].local_port)
|
||||||
|
|
||||||
|
def test_parse_huawei_legacy_and_lab(self) -> None:
|
||||||
|
legacy = lldp.parse_neighbor_output(
|
||||||
|
HUAWEI_LLDP, protocol="lldp", vendor="Huawei", device_type="huawei"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(legacy), 1)
|
||||||
|
self.assertEqual(legacy[0].remote_name, "r1")
|
||||||
|
self.assertEqual(legacy[0].remote_ip, "192.168.0.127")
|
||||||
|
|
||||||
|
lab = lldp.parse_neighbor_output(
|
||||||
|
HUAWEI_LLDP_LAB, protocol="lldp", vendor="Huawei", device_type="huawei_vrp"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(lab), 1)
|
||||||
|
self.assertEqual(lab[0].local_port, "Ethernet1/0/1")
|
||||||
|
self.assertEqual(lab[0].remote_port, "Gi0/1")
|
||||||
|
self.assertEqual(lab[0].remote_name, "R2.example.com")
|
||||||
|
self.assertEqual(lab[0].remote_ip, "192.168.0.128")
|
||||||
|
|
||||||
|
def test_pick_command_auto(self) -> None:
|
||||||
|
cmd, proto = lldp.pick_neighbor_command(protocol="auto", vendor="Cisco", device_type="cisco_ios")
|
||||||
|
self.assertEqual(proto, "lldp")
|
||||||
|
self.assertEqual(cmd, "show lldp neighbors detail")
|
||||||
|
cmd2, proto2 = lldp.pick_neighbor_command(protocol="auto", vendor="Huawei", device_type="huawei")
|
||||||
|
self.assertEqual(proto2, "lldp")
|
||||||
|
self.assertEqual(cmd2, "display lldp neighbor")
|
||||||
|
cmd3, proto3 = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
||||||
|
self.assertEqual(proto3, "cdp")
|
||||||
|
self.assertIn("cdp", cmd3.lower())
|
||||||
|
|
||||||
|
def test_vendor_profiles_cover_requested_vendors(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"cisco": "show lldp neighbors detail",
|
||||||
|
"huawei": "display lldp neighbor",
|
||||||
|
"h3c": "display lldp neighbor-information list",
|
||||||
|
"zte": "show lldp neighbors",
|
||||||
|
"juniper": "show lldp neighbors",
|
||||||
|
"nokia": "show system lldp neighbor",
|
||||||
|
"ericsson": "show lldp neighbors",
|
||||||
|
}
|
||||||
|
# Prefer real Netmiko device_type values from inventory.
|
||||||
|
samples = {
|
||||||
|
"cisco": ("Cisco", "cisco_ios"),
|
||||||
|
"huawei": ("Huawei", "huawei_vrp"),
|
||||||
|
"h3c": ("H3C", "hp_comware"),
|
||||||
|
"zte": ("ZTE", "zte_zxros"),
|
||||||
|
"juniper": ("Juniper", "juniper_junos"),
|
||||||
|
"nokia": ("Nokia", "nokia_sros"),
|
||||||
|
"ericsson": ("Ericsson", "ericsson_ipos"),
|
||||||
|
}
|
||||||
|
for key, (vendor, dtype) in samples.items():
|
||||||
|
self.assertEqual(
|
||||||
|
lldp.resolve_vendor_key(vendor, dtype),
|
||||||
|
key,
|
||||||
|
msg=f"device_type={dtype!r} should map to {key}",
|
||||||
|
)
|
||||||
|
self.assertEqual(lldp.lldp_command_for_vendor(vendor, dtype), expected[key])
|
||||||
|
parser = lldp._VENDOR_PARSERS[key]
|
||||||
|
self.assertIsInstance(parser(""), list)
|
||||||
|
|
||||||
|
# device_type alone is enough (no vendor label).
|
||||||
|
self.assertEqual(lldp.resolve_vendor_key("", "zte_zxros"), "zte")
|
||||||
|
self.assertEqual(lldp.resolve_vendor_key("", "cisco_xe"), "cisco")
|
||||||
|
# vendor label fallback when device_type missing.
|
||||||
|
self.assertEqual(lldp.resolve_vendor_key("ZTE", ""), "zte")
|
||||||
|
|
||||||
|
def test_parse_routes_by_vendor(self) -> None:
|
||||||
|
cisco_hits = lldp.parse_neighbor_output(
|
||||||
|
CISCO_LLDP_DETAIL, protocol="lldp", vendor="Cisco", device_type="cisco_ios"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(cisco_hits), 1)
|
||||||
|
hw_hits = lldp.parse_neighbor_output(
|
||||||
|
HUAWEI_LLDP_LAB, protocol="lldp", vendor="Huawei", device_type="huawei"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(hw_hits), 1)
|
||||||
|
# Placeholder vendors: empty until lab echo wired.
|
||||||
|
self.assertEqual(lldp.parse_juniper_lldp("junk"), [])
|
||||||
|
self.assertEqual(lldp.parse_nokia_lldp("junk"), [])
|
||||||
|
self.assertEqual(lldp.parse_ericsson_lldp("junk"), [])
|
||||||
|
self.assertEqual(lldp.parse_h3c_lldp("junk"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
self.db = SessionLocal()
|
||||||
|
# Clean topology tables between tests
|
||||||
|
for m in self.db.query(TopologyMap).all():
|
||||||
|
svc.delete_map(self.db, m.id)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_map_crud_and_graph_put(self) -> None:
|
||||||
|
created = svc.create_map(self.db, TopologyMapCreate(name="Lab", remark="demo"))
|
||||||
|
self.assertEqual(created.name, "Lab")
|
||||||
|
mid = created.id
|
||||||
|
|
||||||
|
graph = svc.put_graph(
|
||||||
|
self.db,
|
||||||
|
mid,
|
||||||
|
TopologyGraphPut(
|
||||||
|
nodes=[
|
||||||
|
TopologyNodeIn(id="n1", label="A", x=10, y=20, managed_ne_id=""),
|
||||||
|
TopologyNodeIn(id="n2", label="B", x=100, y=20, managed_ne_id=""),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
TopologyEdgeIn(
|
||||||
|
id="e1",
|
||||||
|
source_node_id="n1",
|
||||||
|
target_node_id="n2",
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(graph.nodes), 2)
|
||||||
|
self.assertEqual(len(graph.edges), 1)
|
||||||
|
self.assertEqual(graph.edges[0].source, "manual")
|
||||||
|
|
||||||
|
listed = svc.list_maps(self.db)
|
||||||
|
self.assertGreaterEqual(listed["total"], 1)
|
||||||
|
|
||||||
|
got = svc.get_graph(self.db, mid)
|
||||||
|
self.assertEqual(got.map.id, mid)
|
||||||
|
|
||||||
|
svc.delete_map(self.db, mid)
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
svc.get_graph(self.db, mid)
|
||||||
|
|
||||||
|
def test_discover_matches_by_name_and_skips_manual(self) -> None:
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
ne_a_id = f"nea-{suffix}"
|
||||||
|
ne_b_id = f"neb-{suffix}"
|
||||||
|
# Unique lab IPs in TEST-NET-3
|
||||||
|
ip_a = f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}"
|
||||||
|
ip_b = f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}"
|
||||||
|
ne_a = ManagedNE(
|
||||||
|
id=ne_a_id,
|
||||||
|
name="R2",
|
||||||
|
vendor="Cisco",
|
||||||
|
device_type="cisco_ios",
|
||||||
|
ip_address=ip_a,
|
||||||
|
connect_status="pass",
|
||||||
|
)
|
||||||
|
ne_b = ManagedNE(
|
||||||
|
id=ne_b_id,
|
||||||
|
name="R1",
|
||||||
|
vendor="Cisco",
|
||||||
|
device_type="cisco_ios",
|
||||||
|
ip_address=ip_b,
|
||||||
|
connect_status="pass",
|
||||||
|
)
|
||||||
|
self.db.add(ne_a)
|
||||||
|
self.db.add(ne_b)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
created = svc.create_map(self.db, TopologyMapCreate(name=f"Disc-{suffix}"))
|
||||||
|
mid = created.id
|
||||||
|
svc.put_graph(
|
||||||
|
self.db,
|
||||||
|
mid,
|
||||||
|
TopologyGraphPut(
|
||||||
|
nodes=[
|
||||||
|
TopologyNodeIn(id="n1", managed_ne_id=ne_a_id, label="R2", x=0, y=0),
|
||||||
|
TopologyNodeIn(id="n2", managed_ne_id=ne_b_id, label="R1", x=200, y=0),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
TopologyEdgeIn(
|
||||||
|
id="manual1",
|
||||||
|
source_node_id="n1",
|
||||||
|
target_node_id="n2",
|
||||||
|
source_port="GigabitEthernet0/0",
|
||||||
|
target_port="GigabitEthernet0/1",
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_exec = {
|
||||||
|
"ok": True,
|
||||||
|
"output": CISCO_CDP_DETAIL,
|
||||||
|
"commands": ["show cdp neighbors detail"],
|
||||||
|
}
|
||||||
|
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
|
||||||
|
out = svc.discover_neighbors(
|
||||||
|
self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id])
|
||||||
|
)
|
||||||
|
# Manual edge with same ports should be preserved (not overwritten).
|
||||||
|
self.assertEqual(out.edges_added, 0)
|
||||||
|
graph = svc.get_graph(self.db, mid)
|
||||||
|
manuals = [e for e in graph.edges if e.source == "manual"]
|
||||||
|
self.assertEqual(len(manuals), 1)
|
||||||
|
|
||||||
|
# Clear ports so discovery can add a new edge key.
|
||||||
|
svc.put_graph(
|
||||||
|
self.db,
|
||||||
|
mid,
|
||||||
|
TopologyGraphPut(
|
||||||
|
nodes=[
|
||||||
|
TopologyNodeIn(id="n1", managed_ne_id=ne_a_id, label="R2", x=0, y=0),
|
||||||
|
TopologyNodeIn(id="n2", managed_ne_id=ne_b_id, label="R1", x=200, y=0),
|
||||||
|
],
|
||||||
|
edges=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
|
||||||
|
out2 = svc.discover_neighbors(
|
||||||
|
self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id])
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(out2.edges_added, 1)
|
||||||
|
graph2 = svc.get_graph(self.db, mid)
|
||||||
|
self.assertTrue(any(e.source == "cdp" for e in graph2.edges))
|
||||||
|
|
||||||
|
svc.delete_map(self.db, mid)
|
||||||
|
self.db.delete(ne_a)
|
||||||
|
self.db.delete(ne_b)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
246
web/package-lock.json
generated
246
web/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"@tanstack/react-query": "^5.100.8",
|
"@tanstack/react-query": "^5.100.8",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2"
|
||||||
|
|
@ -881,6 +882,55 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-drag": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-transition": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
|
|
@ -916,7 +966,7 @@
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
|
|
@ -926,7 +976,7 @@
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
|
|
@ -1216,6 +1266,48 @@
|
||||||
"addons/*"
|
"addons/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
|
||||||
|
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.79",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=17",
|
||||||
|
"@types/react-dom": ">=17",
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.79",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
|
||||||
|
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|
@ -1347,6 +1439,12 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
|
@ -1386,9 +1484,114 @@
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -2696,6 +2899,15 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||||
|
|
@ -2842,6 +3054,34 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"@tanstack/react-query": "^5.100.8",
|
"@tanstack/react-query": "^5.100.8",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { CollectPage } from "./pages/CollectPage";
|
||||||
import { NePage } from "./pages/NePage";
|
import { NePage } from "./pages/NePage";
|
||||||
import { UmePage } from "./pages/UmePage";
|
import { UmePage } from "./pages/UmePage";
|
||||||
import { WebcrtPage } from "./pages/WebcrtPage";
|
import { WebcrtPage } from "./pages/WebcrtPage";
|
||||||
|
import { TopologyPage } from "./pages/TopologyPage";
|
||||||
import { fetchIntegrationStatus } from "./services/api";
|
import { fetchIntegrationStatus } from "./services/api";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -51,6 +52,7 @@ function App() {
|
||||||
<Route path="/ne" element={<NePage />} />
|
<Route path="/ne" element={<NePage />} />
|
||||||
<Route path="/collect" element={<CollectPage />} />
|
<Route path="/collect" element={<CollectPage />} />
|
||||||
<Route path="/webcrt" element={<WebcrtPage />} />
|
<Route path="/webcrt" element={<WebcrtPage />} />
|
||||||
|
<Route path="/topology" element={<TopologyPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,15 @@ export const MODULES: readonly ModuleDefinition[] = [
|
||||||
iconTone: "slate",
|
iconTone: "slate",
|
||||||
titleKey: "layout.titleWebcrt",
|
titleKey: "layout.titleWebcrt",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
moduleId: "topology",
|
||||||
|
path: "/topology",
|
||||||
|
section: "operations",
|
||||||
|
labelKey: "workbench.cards.topology",
|
||||||
|
descKey: "workbench.cards.topologyDesc",
|
||||||
|
iconTone: "blue",
|
||||||
|
titleKey: "layout.titleTopology",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,6 @@ export const queryKeys = {
|
||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
||||||
|
topologyMaps: ["topologyMaps"] as const,
|
||||||
|
topologyGraph: (mapId: string) => ["topologyGraph", mapId] as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ const en = {
|
||||||
collectDesc: "Run custom CLI commands on connected NEs and save logs",
|
collectDesc: "Run custom CLI commands on connected NEs and save logs",
|
||||||
webcrt: "WebCRT",
|
webcrt: "WebCRT",
|
||||||
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
|
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
|
||||||
|
topology: "Topology",
|
||||||
|
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
|
|
@ -36,6 +38,7 @@ const en = {
|
||||||
titleManagedNe: "NE Management",
|
titleManagedNe: "NE Management",
|
||||||
titleCollect: "Batch Collect",
|
titleCollect: "Batch Collect",
|
||||||
titleWebcrt: "WebCRT",
|
titleWebcrt: "WebCRT",
|
||||||
|
titleTopology: "Topology",
|
||||||
navUme: "UME",
|
navUme: "UME",
|
||||||
netxApi: "netx api",
|
netxApi: "netx api",
|
||||||
oclawBridge: "oclaw WSS",
|
oclawBridge: "oclaw WSS",
|
||||||
|
|
@ -588,6 +591,49 @@ const en = {
|
||||||
connectFailed: "Device login failed: {{detail}}",
|
connectFailed: "Device login failed: {{detail}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
topology: {
|
||||||
|
title: "Topology",
|
||||||
|
maps: "Maps",
|
||||||
|
newMap: "New",
|
||||||
|
newMapName: "New topology",
|
||||||
|
rename: "Rename",
|
||||||
|
renamePrompt: "Topology map name",
|
||||||
|
renameHint: "Double-click to rename",
|
||||||
|
renamed: "Topology map renamed",
|
||||||
|
deleteMap: "Delete map",
|
||||||
|
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
||||||
|
emptyMaps: "No maps yet. Create one to start.",
|
||||||
|
palette: "NE library",
|
||||||
|
paletteHint: "Click to add to canvas",
|
||||||
|
filterPh: "Filter name / IP",
|
||||||
|
save: "Save",
|
||||||
|
saving: "Saving…",
|
||||||
|
saved: "Topology saved",
|
||||||
|
discover: "Discover links",
|
||||||
|
discovering: "Discovering…",
|
||||||
|
discovered: "Discovery done: +{{added}} updated {{updated}}",
|
||||||
|
discoverFail: "Discovery failed: {{detail}}",
|
||||||
|
selectMap: "Select or create a topology map",
|
||||||
|
canvasHint: "Add NEs from the left, drag and connect; dashed edges are LLDP/CDP",
|
||||||
|
selected: "Selected",
|
||||||
|
openWebcrt: "Open terminal",
|
||||||
|
openNe: "NE details",
|
||||||
|
removeNode: "Remove from canvas",
|
||||||
|
noNeLink: "Not linked to a managed NE",
|
||||||
|
edgeManual: "Manual",
|
||||||
|
edgeDiscovered: "Discovered",
|
||||||
|
fit: "Fit view",
|
||||||
|
display: "Display",
|
||||||
|
hideIp: "Hide IP",
|
||||||
|
hideVendor: "Hide vendor",
|
||||||
|
hidePorts: "Hide ports",
|
||||||
|
edgeFlow: "Edge flow",
|
||||||
|
collapseSidebar: "Collapse sidebar",
|
||||||
|
expandSidebar: "Expand sidebar",
|
||||||
|
hideAdded: "Hide added",
|
||||||
|
paletteEmpty: "No matching NEs.",
|
||||||
|
paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ const zh = {
|
||||||
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
|
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
|
||||||
webcrt: "WebCRT",
|
webcrt: "WebCRT",
|
||||||
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
|
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
|
||||||
|
topology: "拓扑管理",
|
||||||
|
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
|
|
@ -36,6 +38,7 @@ const zh = {
|
||||||
titleManagedNe: "网元管理",
|
titleManagedNe: "网元管理",
|
||||||
titleCollect: "批量采集",
|
titleCollect: "批量采集",
|
||||||
titleWebcrt: "WebCRT",
|
titleWebcrt: "WebCRT",
|
||||||
|
titleTopology: "拓扑管理",
|
||||||
navUme: "UME 对接",
|
navUme: "UME 对接",
|
||||||
netxApi: "netx api",
|
netxApi: "netx api",
|
||||||
oclawBridge: "oclaw WSS",
|
oclawBridge: "oclaw WSS",
|
||||||
|
|
@ -586,6 +589,49 @@ const zh = {
|
||||||
connectFailed: "登录设备失败:{{detail}}",
|
connectFailed: "登录设备失败:{{detail}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
topology: {
|
||||||
|
title: "拓扑管理",
|
||||||
|
maps: "拓扑图",
|
||||||
|
newMap: "新建",
|
||||||
|
newMapName: "新拓扑图",
|
||||||
|
rename: "重命名",
|
||||||
|
renamePrompt: "拓扑图名称",
|
||||||
|
renameHint: "双击可重命名",
|
||||||
|
renamed: "拓扑图已重命名",
|
||||||
|
deleteMap: "删除图",
|
||||||
|
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
||||||
|
emptyMaps: "还没有拓扑图,先新建一张。",
|
||||||
|
palette: "网元库",
|
||||||
|
paletteHint: "点击添加到画布",
|
||||||
|
filterPh: "筛选名称 / IP",
|
||||||
|
save: "保存",
|
||||||
|
saving: "保存中…",
|
||||||
|
saved: "拓扑已保存",
|
||||||
|
discover: "发现链路",
|
||||||
|
discovering: "发现中…",
|
||||||
|
discovered: "发现完成:新增 {{added}},更新 {{updated}}",
|
||||||
|
discoverFail: "发现失败:{{detail}}",
|
||||||
|
selectMap: "请选择或新建一张拓扑图",
|
||||||
|
canvasHint: "从左侧添加网元,拖动节点并连线;虚线为 LLDP/CDP 发现链路",
|
||||||
|
selected: "已选节点",
|
||||||
|
openWebcrt: "打开终端",
|
||||||
|
openNe: "网元详情",
|
||||||
|
removeNode: "从画布移除",
|
||||||
|
noNeLink: "未关联托管网元",
|
||||||
|
edgeManual: "人工",
|
||||||
|
edgeDiscovered: "发现",
|
||||||
|
fit: "适应画布",
|
||||||
|
display: "显示选项",
|
||||||
|
hideIp: "隐藏 IP",
|
||||||
|
hideVendor: "隐藏厂商",
|
||||||
|
hidePorts: "隐藏端口",
|
||||||
|
edgeFlow: "链路流动",
|
||||||
|
collapseSidebar: "折叠侧栏",
|
||||||
|
expandSidebar: "展开侧栏",
|
||||||
|
hideAdded: "隐藏已添加",
|
||||||
|
paletteEmpty: "没有匹配的网元。",
|
||||||
|
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default zh;
|
export default zh;
|
||||||
|
|
|
||||||
|
|
@ -1836,3 +1836,380 @@ pre {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Topology ---- */
|
||||||
|
.topo-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
height: calc(100vh - 72px);
|
||||||
|
min-height: 520px;
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
transition: grid-template-columns 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-page.is-sidebar-collapsed {
|
||||||
|
grid-template-columns: 40px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe3ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-page.is-sidebar-collapsed .topo-sidebar {
|
||||||
|
padding: 6px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__section--grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list,
|
||||||
|
.topo-palette {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list li.is-active {
|
||||||
|
background: #e8f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__item,
|
||||||
|
.topo-palette__item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__item:hover,
|
||||||
|
.topo-palette__item:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-palette__item:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-palette__empty {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__name,
|
||||||
|
.topo-palette__name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__meta,
|
||||||
|
.topo-palette__meta {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe3ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-display-toggles {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-display-toggles__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-selection__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node {
|
||||||
|
min-width: 72px;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
--topo-vendor: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node--cisco { --topo-vendor: #1d4ed8; }
|
||||||
|
.topo-node--huawei { --topo-vendor: #c62828; }
|
||||||
|
.topo-node--zte { --topo-vendor: #0277bd; }
|
||||||
|
.topo-node--juniper { --topo-vendor: #2e7d32; }
|
||||||
|
.topo-node--nokia { --topo-vendor: #00838f; }
|
||||||
|
.topo-node--ericsson { --topo-vendor: #f9a825; }
|
||||||
|
.topo-node--h3c { --topo-vendor: #ef6c00; }
|
||||||
|
.topo-node--other { --topo-vendor: #546e7a; }
|
||||||
|
|
||||||
|
.topo-node__glyph {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__handle {
|
||||||
|
width: 12px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
min-width: 12px !important;
|
||||||
|
min-height: 12px !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__handle--center {
|
||||||
|
left: 50% !important;
|
||||||
|
top: 50% !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node.is-selected .topo-node__icon-body {
|
||||||
|
stroke: #0f172a;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node.is-selected .topo-node__caption {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__icon {
|
||||||
|
display: block;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__icon-shadow {
|
||||||
|
fill: rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__icon-rim {
|
||||||
|
fill: color-mix(in srgb, var(--topo-vendor, #64748b) 70%, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__icon-body {
|
||||||
|
fill: var(--topo-vendor, #64748b);
|
||||||
|
stroke: color-mix(in srgb, var(--topo-vendor, #64748b) 45%, #0f172a);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__icon-face {
|
||||||
|
fill: color-mix(in srgb, var(--topo-vendor, #64748b) 62%, #cbd5e1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__icon-arrows {
|
||||||
|
fill: #ffffff;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same plain-text style as edge port labels */
|
||||||
|
.topo-node__caption {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge-textbg {
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge-text {
|
||||||
|
fill: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.topo-page,
|
||||||
|
.topo-page.is-sidebar-collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar {
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-page.is-sidebar-collapsed .topo-sidebar {
|
||||||
|
max-height: none;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail-label {
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
718
web/src/pages/TopologyPage.tsx
Normal file
718
web/src/pages/TopologyPage.tsx
Normal file
|
|
@ -0,0 +1,718 @@
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
addEdge,
|
||||||
|
useEdgesState,
|
||||||
|
useNodesState,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
MarkerType,
|
||||||
|
ConnectionMode,
|
||||||
|
type Connection,
|
||||||
|
type Edge,
|
||||||
|
type Node,
|
||||||
|
type NodeProps,
|
||||||
|
type ReactFlowInstance,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import {
|
||||||
|
createTopologyMap,
|
||||||
|
deleteTopologyMap,
|
||||||
|
discoverTopologyNeighbors,
|
||||||
|
fetchManagedNe,
|
||||||
|
fetchTopologyGraph,
|
||||||
|
fetchTopologyMaps,
|
||||||
|
putTopologyGraph,
|
||||||
|
updateTopologyMap,
|
||||||
|
} from "../services/api";
|
||||||
|
import { queryKeys } from "../constants/queryKeys";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
import { useToast } from "../hooks/useToast";
|
||||||
|
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
|
||||||
|
import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem } from "../types";
|
||||||
|
|
||||||
|
type NeNodeData = {
|
||||||
|
label: string;
|
||||||
|
managed_ne_id: string;
|
||||||
|
ume_ne_id: string;
|
||||||
|
ne_ip: string;
|
||||||
|
vendor: string;
|
||||||
|
connect_status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TopoDisplayOpts = {
|
||||||
|
hideIp: boolean;
|
||||||
|
hideVendor: boolean;
|
||||||
|
hidePorts: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||||
|
hideIp: true,
|
||||||
|
hideVendor: true,
|
||||||
|
hidePorts: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID().replace(/-/g, "");
|
||||||
|
}
|
||||||
|
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vendorTone(vendor: string): string {
|
||||||
|
const v = String(vendor || "").trim().toLowerCase();
|
||||||
|
if (v.includes("cisco")) return "cisco";
|
||||||
|
if (v.includes("huawei")) return "huawei";
|
||||||
|
if (v.includes("zte")) return "zte";
|
||||||
|
if (v.includes("juniper")) return "juniper";
|
||||||
|
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
|
||||||
|
if (v.includes("ericsson")) return "ericsson";
|
||||||
|
if (v.includes("h3c") || v.includes("comware")) return "h3c";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classic Visio/Packet Tracer style router: disc + four outbound arrows. */
|
||||||
|
function RouterIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="topo-node__icon" viewBox="0 0 64 64" aria-hidden="true">
|
||||||
|
<ellipse cx="32" cy="39" rx="19" ry="8.5" className="topo-node__icon-shadow" />
|
||||||
|
<path
|
||||||
|
d="M13 31 A19 9.5 0 0 0 51 31 L51 36.5 A19 9.5 0 0 1 13 36.5 Z"
|
||||||
|
className="topo-node__icon-rim"
|
||||||
|
/>
|
||||||
|
<ellipse cx="32" cy="31" rx="19" ry="9.5" className="topo-node__icon-body" />
|
||||||
|
<ellipse cx="32" cy="30" rx="15.5" ry="7" className="topo-node__icon-face" />
|
||||||
|
<g className="topo-node__icon-arrows">
|
||||||
|
{/* NW / NE / SW / SE */}
|
||||||
|
<path d="M31 29 L19 18 L24.5 18.8 L23.2 23.8 Z" />
|
||||||
|
<path d="M33 29 L45 18 L39.5 18.8 L40.8 23.8 Z" />
|
||||||
|
<path d="M31 31 L19 42 L24.5 41.2 L23.2 36.2 Z" />
|
||||||
|
<path d="M33 31 L45 42 L39.5 41.2 L40.8 36.2 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||||
|
const { hideIp, hideVendor } = useContext(TopoDisplayContext);
|
||||||
|
const tone = vendorTone(data.vendor);
|
||||||
|
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
|
||||||
|
const bits = [
|
||||||
|
name,
|
||||||
|
hideIp ? "" : data.ne_ip,
|
||||||
|
hideVendor ? "" : data.vendor,
|
||||||
|
].filter((x, i, arr) => {
|
||||||
|
const s = String(x || "").trim();
|
||||||
|
if (!s) return false;
|
||||||
|
return arr.findIndex((y) => String(y || "").trim() === s) === i;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}`}>
|
||||||
|
<div className="topo-node__glyph">
|
||||||
|
{/* Centered on the disc so edges meet the circular icon, not the caption. */}
|
||||||
|
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--center" />
|
||||||
|
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--center" />
|
||||||
|
<RouterIcon />
|
||||||
|
</div>
|
||||||
|
<div className="topo-node__caption">{bits.join(" · ")}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = { neNode: NeNode };
|
||||||
|
|
||||||
|
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
|
||||||
|
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: "neNode",
|
||||||
|
position: { x: n.x || 0, y: n.y || 0 },
|
||||||
|
data: {
|
||||||
|
label: n.label || n.ne_name || n.ne_ip || n.id,
|
||||||
|
managed_ne_id: n.managed_ne_id || "",
|
||||||
|
ume_ne_id: n.ume_ne_id || "",
|
||||||
|
ne_ip: n.ne_ip || "",
|
||||||
|
vendor: n.vendor || "",
|
||||||
|
connect_status: n.connect_status || "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const rfEdges: Edge[] = edges.map((e) => {
|
||||||
|
const discovered = e.source === "lldp" || e.source === "cdp";
|
||||||
|
const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
source: e.source_node_id,
|
||||||
|
target: e.target_node_id,
|
||||||
|
type: "straight",
|
||||||
|
label: label || undefined,
|
||||||
|
animated: false,
|
||||||
|
style: discovered
|
||||||
|
? { stroke: "#0ea5e9", strokeDasharray: "6 4" }
|
||||||
|
: { stroke: "#64748b" },
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
||||||
|
data: {
|
||||||
|
source: e.source || "manual",
|
||||||
|
source_port: e.source_port || "",
|
||||||
|
target_port: e.target_port || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { rfNodes, rfEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowToGraphPayload(nodes: Node<NeNodeData>[], edges: Edge[]) {
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
managed_ne_id: n.data.managed_ne_id || "",
|
||||||
|
ume_ne_id: n.data.ume_ne_id || "",
|
||||||
|
label: n.data.label || "",
|
||||||
|
x: n.position.x,
|
||||||
|
y: n.position.y,
|
||||||
|
})),
|
||||||
|
edges: edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source_node_id: e.source,
|
||||||
|
target_node_id: e.target,
|
||||||
|
source_port: String((e.data as { source_port?: string } | undefined)?.source_port || ""),
|
||||||
|
target_port: String((e.data as { target_port?: string } | undefined)?.target_port || ""),
|
||||||
|
source: String((e.data as { source?: string } | undefined)?.source || "manual"),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopologyPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { showOk, showError } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [mapId, setMapId] = useState<string>("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
const [hideIp, setHideIp] = useState(true);
|
||||||
|
const [hideVendor, setHideVendor] = useState(true);
|
||||||
|
const [hidePorts, setHidePorts] = useState(true);
|
||||||
|
const [edgeFlow, setEdgeFlow] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [hideAddedNes, setHideAddedNes] = useState(true);
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const displayOpts = useMemo(
|
||||||
|
() => ({ hideIp, hideVendor, hidePorts }),
|
||||||
|
[hideIp, hideVendor, hidePorts],
|
||||||
|
);
|
||||||
|
const displayEdges = useMemo(
|
||||||
|
() =>
|
||||||
|
edges.map((e) => ({
|
||||||
|
...e,
|
||||||
|
label: hidePorts ? undefined : e.label,
|
||||||
|
animated: edgeFlow,
|
||||||
|
})),
|
||||||
|
[edges, hidePorts, edgeFlow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.topologyMaps,
|
||||||
|
queryFn: fetchTopologyMaps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const graphQuery = useQuery({
|
||||||
|
queryKey: queryKeys.topologyGraph(mapId),
|
||||||
|
queryFn: () => fetchTopologyGraph(mapId),
|
||||||
|
enabled: Boolean(mapId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const neQuery = useQuery({
|
||||||
|
queryKey: [...queryKeys.managedNeAll, "topology-palette", keyword],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchManagedNe({
|
||||||
|
keyword: keyword.trim(),
|
||||||
|
vendor: "",
|
||||||
|
connectStatus: "",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapId && mapsQuery.data?.items?.length) {
|
||||||
|
setMapId(mapsQuery.data.items[0].id);
|
||||||
|
}
|
||||||
|
}, [mapId, mapsQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!graphQuery.data) return;
|
||||||
|
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges);
|
||||||
|
setNodes(rfNodes);
|
||||||
|
setEdges(rfEdges);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||||
|
}, [graphQuery.data, setNodes, setEdges]);
|
||||||
|
|
||||||
|
const renameMapMut = useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||||
|
updateTopologyMap(id, { name }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
showOk(t("topology.renamed"));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptRenameMap = useCallback(
|
||||||
|
(id: string, currentName: string) => {
|
||||||
|
const next = window.prompt(t("topology.renamePrompt"), currentName);
|
||||||
|
if (next == null) return;
|
||||||
|
const name = next.trim();
|
||||||
|
if (!name || name === currentName) return;
|
||||||
|
renameMapMut.mutate({ id, name });
|
||||||
|
},
|
||||||
|
[renameMapMut, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMapMut = useMutation({
|
||||||
|
mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }),
|
||||||
|
onSuccess: async (row) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
setMapId(row.id);
|
||||||
|
showOk(t("topology.newMap"));
|
||||||
|
// Prompt rename right after create — default name is a placeholder.
|
||||||
|
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMapMut = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteTopologyMap(id),
|
||||||
|
onSuccess: async (_out, id) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
if (mapId === id) {
|
||||||
|
setMapId("");
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
|
||||||
|
onSuccess: async (graph) => {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
showOk(t("topology.saved"));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const discoverMut = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (dirtyRef.current) {
|
||||||
|
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
||||||
|
}
|
||||||
|
return discoverTopologyNeighbors(mapId, { protocol: "auto" });
|
||||||
|
},
|
||||||
|
onSuccess: async (out) => {
|
||||||
|
if (out.graph) {
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
|
||||||
|
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
|
||||||
|
setNodes(rfNodes);
|
||||||
|
setEdges(rfEdges);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
showOk(
|
||||||
|
t("topology.discovered")
|
||||||
|
.replace("{{added}}", String(out.edges_added))
|
||||||
|
.replace("{{updated}}", String(out.edges_updated)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
showError(t("topology.discoverFail").replace("{{detail}}", String(err))),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(connection: Connection) => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setEdges((eds) =>
|
||||||
|
addEdge(
|
||||||
|
{
|
||||||
|
...connection,
|
||||||
|
id: newId(),
|
||||||
|
type: "straight",
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
||||||
|
style: { stroke: "#64748b" },
|
||||||
|
data: { source: "manual", source_port: "", target_port: "" },
|
||||||
|
},
|
||||||
|
eds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNeToCanvas = (ne: ManagedNeItem) => {
|
||||||
|
if (!mapId) {
|
||||||
|
showError(t("topology.selectMap"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nodes.some((n) => n.data.managed_ne_id === ne.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const offset = nodes.length * 24;
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setNodes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: newId(),
|
||||||
|
type: "neNode",
|
||||||
|
position: { x: 80 + offset, y: 80 + offset },
|
||||||
|
data: {
|
||||||
|
label: ne.name || ne.ip_address,
|
||||||
|
managed_ne_id: ne.id,
|
||||||
|
ume_ne_id: "",
|
||||||
|
ne_ip: ne.ip_address,
|
||||||
|
vendor: ne.vendor,
|
||||||
|
connect_status: ne.connect_status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedNode = useMemo(
|
||||||
|
() => nodes.find((n) => n.id === selectedNodeId) || null,
|
||||||
|
[nodes, selectedNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeSelected = () => {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId));
|
||||||
|
setEdges((es) => es.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId));
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWebcrt = () => {
|
||||||
|
const neId = selectedNode?.data.managed_ne_id;
|
||||||
|
if (!neId) {
|
||||||
|
showError(t("topology.noNeLink"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openNewModuleWindow({
|
||||||
|
moduleId: "webcrt",
|
||||||
|
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNe = () => {
|
||||||
|
const neId = selectedNode?.data.managed_ne_id;
|
||||||
|
if (!neId) {
|
||||||
|
showError(t("topology.noNeLink"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openOrFocusModule({
|
||||||
|
moduleId: "managed-ne",
|
||||||
|
path: "/ne",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const maps = mapsQuery.data?.items || [];
|
||||||
|
const palette = neQuery.data?.items || [];
|
||||||
|
const onCanvasIds = useMemo(
|
||||||
|
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
||||||
|
[nodes],
|
||||||
|
);
|
||||||
|
const paletteVisible = useMemo(
|
||||||
|
() => (hideAddedNes ? palette.filter((ne) => !onCanvasIds.has(ne.id)) : palette),
|
||||||
|
[palette, hideAddedNes, onCanvasIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
||||||
|
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-sidebar__rail"
|
||||||
|
title={t("topology.expandSidebar")}
|
||||||
|
aria-label={t("topology.expandSidebar")}
|
||||||
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
|
>
|
||||||
|
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<span className="topo-sidebar__rail-label">{t("topology.maps")}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="topo-sidebar__section">
|
||||||
|
<div className="topo-sidebar__head">
|
||||||
|
<strong>{t("topology.maps")}</strong>
|
||||||
|
<div className="topo-sidebar__head-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
onClick={() => createMapMut.mutate()}
|
||||||
|
disabled={createMapMut.isPending}
|
||||||
|
>
|
||||||
|
{t("topology.newMap")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
title={t("topology.collapseSidebar")}
|
||||||
|
aria-label={t("topology.collapseSidebar")}
|
||||||
|
onClick={() => setSidebarCollapsed(true)}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{maps.length === 0 ? (
|
||||||
|
<p className="panel__hint">{t("topology.emptyMaps")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="topo-map-list">
|
||||||
|
{maps.map((m) => (
|
||||||
|
<li key={m.id} className={mapId === m.id ? "is-active" : ""}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__item"
|
||||||
|
onClick={() => setMapId(m.id)}
|
||||||
|
onDoubleClick={() => promptRenameMap(m.id, m.name)}
|
||||||
|
title={t("topology.renameHint")}
|
||||||
|
>
|
||||||
|
<span className="topo-map-list__name">{m.name}</span>
|
||||||
|
<span className="topo-map-list__meta">
|
||||||
|
{m.node_count}N / {m.edge_count}E
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
title={t("topology.rename")}
|
||||||
|
disabled={renameMapMut.isPending}
|
||||||
|
onClick={() => promptRenameMap(m.id, m.name)}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
title={t("topology.deleteMap")}
|
||||||
|
onClick={() => {
|
||||||
|
const msg = t("topology.deleteMapConfirm").replace("{{name}}", m.name);
|
||||||
|
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topo-sidebar__section topo-sidebar__section--grow">
|
||||||
|
<div className="topo-sidebar__head">
|
||||||
|
<strong>{t("topology.palette")}</strong>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideAddedNes}
|
||||||
|
onChange={(e) => setHideAddedNes(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.hideAdded")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint">{t("topology.paletteHint")}</p>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
placeholder={t("topology.filterPh")}
|
||||||
|
/>
|
||||||
|
<ul className="topo-palette">
|
||||||
|
{paletteVisible.length === 0 ? (
|
||||||
|
<li className="topo-palette__empty">
|
||||||
|
<span className="panel__hint">
|
||||||
|
{palette.length === 0
|
||||||
|
? t("topology.paletteEmpty")
|
||||||
|
: t("topology.paletteHiddenAll")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
paletteVisible.map((ne) => {
|
||||||
|
const onCanvas = onCanvasIds.has(ne.id);
|
||||||
|
return (
|
||||||
|
<li key={ne.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-palette__item"
|
||||||
|
disabled={!mapId || onCanvas}
|
||||||
|
onClick={() => addNeToCanvas(ne)}
|
||||||
|
title={`${ne.name}\n${ne.ip_address}`}
|
||||||
|
>
|
||||||
|
<span className="topo-palette__name">{ne.name || ne.ip_address}</span>
|
||||||
|
<span className="topo-palette__meta">
|
||||||
|
{ne.ip_address} · {ne.vendor}
|
||||||
|
{onCanvas ? " ✓" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="topo-main">
|
||||||
|
<div className="topo-toolbar">
|
||||||
|
<div className="topo-toolbar__left">
|
||||||
|
<div className="topo-toolbar__title">
|
||||||
|
<strong>{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")}</strong>
|
||||||
|
{mapId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
title={t("topology.rename")}
|
||||||
|
disabled={renameMapMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const current = maps.find((m) => m.id === mapId)?.name || "";
|
||||||
|
promptRenameMap(mapId, current);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topology.rename")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="panel__hint">{t("topology.canvasHint")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="topo-toolbar__actions">
|
||||||
|
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideIp}
|
||||||
|
onChange={(e) => setHideIp(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.hideIp")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideVendor}
|
||||||
|
onChange={(e) => setHideVendor(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.hideVendor")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hidePorts}
|
||||||
|
onChange={(e) => setHidePorts(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.hidePorts")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={edgeFlow}
|
||||||
|
onChange={(e) => setEdgeFlow(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.edgeFlow")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={!mapId || saveMut.isPending}
|
||||||
|
onClick={() => saveMut.mutate()}
|
||||||
|
>
|
||||||
|
{saveMut.isPending ? t("topology.saving") : t("topology.save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={!mapId || discoverMut.isPending}
|
||||||
|
onClick={() => discoverMut.mutate()}
|
||||||
|
>
|
||||||
|
{discoverMut.isPending ? t("topology.discovering") : t("topology.discover")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!mapId}
|
||||||
|
onClick={() => rfRef.current?.fitView({ padding: 0.2 })}
|
||||||
|
>
|
||||||
|
{t("topology.fit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedNode ? (
|
||||||
|
<div className="topo-selection">
|
||||||
|
<span>
|
||||||
|
{t("topology.selected")}: <strong>{selectedNode.data.label}</strong>
|
||||||
|
{selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""}
|
||||||
|
</span>
|
||||||
|
<div className="topo-selection__actions">
|
||||||
|
<button type="button" className="btn btn--sm" onClick={openWebcrt}>
|
||||||
|
{t("topology.openWebcrt")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn--sm" onClick={openNe}>
|
||||||
|
{t("topology.openNe")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
|
||||||
|
{t("topology.removeNode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="topo-canvas">
|
||||||
|
{mapId ? (
|
||||||
|
<TopoDisplayContext.Provider value={displayOpts}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={displayEdges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
defaultEdgeOptions={{ type: "straight" }}
|
||||||
|
onNodesChange={(changes) => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
onNodesChange(changes);
|
||||||
|
}}
|
||||||
|
onEdgesChange={(changes) => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
onEdgesChange(changes);
|
||||||
|
}}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeClick={(_e, node) => setSelectedNodeId(node.id)}
|
||||||
|
onPaneClick={() => setSelectedNodeId(null)}
|
||||||
|
onInit={(inst) => {
|
||||||
|
rfRef.current = inst;
|
||||||
|
}}
|
||||||
|
fitView
|
||||||
|
deleteKeyCode={["Backspace", "Delete"]}
|
||||||
|
>
|
||||||
|
<Background gap={18} size={1} />
|
||||||
|
<Controls />
|
||||||
|
<MiniMap pannable zoomable />
|
||||||
|
</ReactFlow>
|
||||||
|
</TopoDisplayContext.Provider>
|
||||||
|
) : (
|
||||||
|
<div className="topo-canvas__empty">{t("topology.selectMap")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,9 @@ import type {
|
||||||
CliMeta,
|
CliMeta,
|
||||||
CliTargetListResponse,
|
CliTargetListResponse,
|
||||||
UmeCliOverrideItem,
|
UmeCliOverrideItem,
|
||||||
|
TopologyDiscoverOut,
|
||||||
|
TopologyGraph,
|
||||||
|
TopologyMapItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||||
|
|
@ -66,6 +69,17 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||||
return data as T;
|
return data as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await parseApiResponse(res);
|
||||||
|
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
||||||
|
|
||||||
export const fetchUmeAlarmSubscriptionStatus = () =>
|
export const fetchUmeAlarmSubscriptionStatus = () =>
|
||||||
|
|
@ -411,3 +425,51 @@ export const fetchUmeCurrentAlarms = (params: {
|
||||||
`/v1/ume/alarms?${p.toString()}`,
|
`/v1/ume/alarms?${p.toString()}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchTopologyMaps = () =>
|
||||||
|
apiGet<{ total: number; items: TopologyMapItem[] }>("/v1/topology/maps");
|
||||||
|
|
||||||
|
export const createTopologyMap = (body: { name: string; remark?: string }) =>
|
||||||
|
apiPost<TopologyMapItem>("/v1/topology/maps", body);
|
||||||
|
|
||||||
|
export const fetchTopologyGraph = (mapId: string) =>
|
||||||
|
apiGet<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}`);
|
||||||
|
|
||||||
|
export const updateTopologyMap = (mapId: string, body: { name?: string; remark?: string }) =>
|
||||||
|
apiPatch<TopologyMapItem>(`/v1/topology/maps/${encodeURIComponent(mapId)}`, body);
|
||||||
|
|
||||||
|
export const deleteTopologyMap = (mapId: string) =>
|
||||||
|
apiDelete<{ ok: boolean; map_id: string; deleted: boolean }>(
|
||||||
|
`/v1/topology/maps/${encodeURIComponent(mapId)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const putTopologyGraph = (
|
||||||
|
mapId: string,
|
||||||
|
body: {
|
||||||
|
nodes: Array<{
|
||||||
|
id: string;
|
||||||
|
managed_ne_id?: string;
|
||||||
|
ume_ne_id?: string;
|
||||||
|
label?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
}>;
|
||||||
|
edges: Array<{
|
||||||
|
id: string;
|
||||||
|
source_node_id: string;
|
||||||
|
target_node_id: string;
|
||||||
|
source_port?: string;
|
||||||
|
target_port?: string;
|
||||||
|
source?: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);
|
||||||
|
|
||||||
|
export const discoverTopologyNeighbors = (
|
||||||
|
mapId: string,
|
||||||
|
body?: { protocol?: string; ne_ids?: string[] },
|
||||||
|
) =>
|
||||||
|
apiPost<TopologyDiscoverOut>(
|
||||||
|
`/v1/topology/maps/${encodeURIComponent(mapId)}/discover`,
|
||||||
|
body || {},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -343,3 +343,68 @@ export type UmeTokenStatus = {
|
||||||
error_kind?: string;
|
error_kind?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TopologyMapItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
remark: string;
|
||||||
|
node_count: number;
|
||||||
|
edge_count: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyNodeItem = {
|
||||||
|
id: string;
|
||||||
|
map_id: string;
|
||||||
|
managed_ne_id: string;
|
||||||
|
ume_ne_id: string;
|
||||||
|
label: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
ne_name: string;
|
||||||
|
ne_ip: string;
|
||||||
|
vendor: string;
|
||||||
|
protocol: string;
|
||||||
|
connect_status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyEdgeItem = {
|
||||||
|
id: string;
|
||||||
|
map_id: string;
|
||||||
|
source_node_id: string;
|
||||||
|
target_node_id: string;
|
||||||
|
source_port: string;
|
||||||
|
target_port: string;
|
||||||
|
source: string;
|
||||||
|
discovered_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyGraph = {
|
||||||
|
map: TopologyMapItem;
|
||||||
|
nodes: TopologyNodeItem[];
|
||||||
|
edges: TopologyEdgeItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyDiscoverNeResult = {
|
||||||
|
ne_id: string;
|
||||||
|
ne_name: string;
|
||||||
|
ne_ip: string;
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
neighbors: number;
|
||||||
|
edges_added: number;
|
||||||
|
edges_updated: number;
|
||||||
|
error: string;
|
||||||
|
raw_preview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyDiscoverOut = {
|
||||||
|
map_id: string;
|
||||||
|
protocol: string;
|
||||||
|
scanned: number;
|
||||||
|
edges_added: number;
|
||||||
|
edges_updated: number;
|
||||||
|
results: TopologyDiscoverNeResult[];
|
||||||
|
graph: TopologyGraph | null;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue