mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add topology maps with LLDP discovery and React Flow canvas.
Includes map CRUD, graph save, neighbor discover, and UI controls for labels, sidebar, and edge flow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3b8342acfb
commit
dc805c2086
18 changed files with 3084 additions and 4 deletions
|
|
@ -25,6 +25,7 @@ from .collection_router import router as collection_router
|
|||
from .cli_router import router as cli_router
|
||||
from .managed_ne_router import router as managed_ne_router
|
||||
from .webcrt_router import router as webcrt_router
|
||||
from .topology_router import router as topology_router
|
||||
from .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
||||
from .models import (
|
||||
AiAnalyzeHistory,
|
||||
|
|
@ -125,6 +126,7 @@ app.include_router(managed_ne_router)
|
|||
app.include_router(cli_router)
|
||||
app.include_router(collection_router)
|
||||
app.include_router(webcrt_router)
|
||||
app.include_router(topology_router)
|
||||
parser_cfg = load_parser_config()
|
||||
_UME_CLIENT_SINGLETON = UMEClient(
|
||||
token_loader=lambda: load_shared_token(),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
|
@ -384,3 +384,49 @@ class NeCollectionRun(Base):
|
|||
output_rel_path: Mapped[str] = mapped_column(String(1024), default="")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class TopologyMap(Base):
|
||||
"""Named topology canvas (document-style graph)."""
|
||||
|
||||
__tablename__ = "topology_map"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
remark: Mapped[str] = mapped_column(String(1024), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class TopologyNode(Base):
|
||||
"""Node on a topology map; preferably references an inventory NE."""
|
||||
|
||||
__tablename__ = "topology_node"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
map_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
managed_ne_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
label: Mapped[str] = mapped_column(String(256), default="")
|
||||
x: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
y: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class TopologyEdge(Base):
|
||||
"""Link between two topology nodes."""
|
||||
|
||||
__tablename__ = "topology_edge"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
map_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source_node_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
target_node_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source_port: Mapped[str] = mapped_column(String(128), default="")
|
||||
target_port: Mapped[str] = mapped_column(String(128), default="")
|
||||
# manual | lldp | cdp
|
||||
source: Mapped[str] = mapped_column(String(32), default="manual", index=True)
|
||||
discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
|
|
|||
497
netx_api/topology_lldp.py
Normal file
497
netx_api/topology_lldp.py
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
"""LLDP/CDP neighbor command templates and output parsers (per vendor).
|
||||
|
||||
Multi-vendor fabrics default to LLDP. Resolve profile primarily from Netmiko
|
||||
``device_type`` (managed NE / UME already store it), then fall back to vendor label.
|
||||
|
||||
Each vendor has:
|
||||
- a show/display command
|
||||
- a dedicated parse_* stub (fill with real lab echoes later)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NeighborHit:
|
||||
remote_name: str = ""
|
||||
remote_ip: str = ""
|
||||
local_port: str = ""
|
||||
remote_port: str = ""
|
||||
protocol: str = "lldp" # lldp | cdp
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VendorLldpProfile:
|
||||
"""One vendor's LLDP (and optional CDP) discovery profile."""
|
||||
|
||||
key: str
|
||||
lldp_command: str
|
||||
cdp_command: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
ParserFn = Callable[[str], list[NeighborHit]]
|
||||
|
||||
_IPV4_RE = re.compile(
|
||||
r"(?<![\d.])(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?![\d.])"
|
||||
)
|
||||
|
||||
# device_type (Netmiko) -> profile key. Prefer inventory device_type over fuzzy text.
|
||||
# Keep aligned with netx_api.device_types.SUPPORTED_DEVICE_TYPES families.
|
||||
_DEVICE_TYPE_PREFIX_RULES: tuple[tuple[str, str], ...] = (
|
||||
("cisco_", "cisco"),
|
||||
("huawei", "huawei"), # huawei, huawei_vrp, huawei_olt, ...
|
||||
("zte_", "zte"),
|
||||
("juniper", "juniper"), # juniper, juniper_junos, ...
|
||||
("nokia_", "nokia"),
|
||||
("alcatel_sros", "nokia"),
|
||||
("alcatel_", "nokia"),
|
||||
("ericsson_", "ericsson"),
|
||||
("hp_comware", "h3c"),
|
||||
("h3c_", "h3c"),
|
||||
)
|
||||
|
||||
_VENDOR_LABEL_TO_KEY: dict[str, str] = {
|
||||
"cisco": "cisco",
|
||||
"huawei": "huawei",
|
||||
"h3c": "h3c",
|
||||
"zte": "zte",
|
||||
"juniper": "juniper",
|
||||
"nokia": "nokia",
|
||||
"ericsson": "ericsson",
|
||||
"alcatel": "nokia",
|
||||
"alcatel-lucent": "nokia",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vendor registry — command templates (edit / refine with lab echoes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
|
||||
"cisco": VendorLldpProfile(
|
||||
key="cisco",
|
||||
lldp_command="show lldp neighbors detail",
|
||||
cdp_command="show cdp neighbors detail",
|
||||
notes="device_type cisco_*; use detail form for System Name / Port id.",
|
||||
),
|
||||
"huawei": VendorLldpProfile(
|
||||
key="huawei",
|
||||
lldp_command="display lldp neighbor",
|
||||
notes="device_type huawei*; per-interface neighbor blocks.",
|
||||
),
|
||||
"h3c": VendorLldpProfile(
|
||||
key="h3c",
|
||||
lldp_command="display lldp neighbor-information list",
|
||||
notes="Placeholder Comware command; confirm on lab.",
|
||||
),
|
||||
"zte": VendorLldpProfile(
|
||||
key="zte",
|
||||
lldp_command="show lldp neighbors",
|
||||
notes="device_type zte_*; confirm ZXROS keyword on lab.",
|
||||
),
|
||||
"juniper": VendorLldpProfile(
|
||||
key="juniper",
|
||||
lldp_command="show lldp neighbors",
|
||||
notes="device_type juniper*; detail form TBD.",
|
||||
),
|
||||
"nokia": VendorLldpProfile(
|
||||
key="nokia",
|
||||
lldp_command="show system lldp neighbor",
|
||||
notes="device_type nokia_* / alcatel_*; SRL may differ.",
|
||||
),
|
||||
"ericsson": VendorLldpProfile(
|
||||
key="ericsson",
|
||||
lldp_command="show lldp neighbors",
|
||||
notes="device_type ericsson_*; confirm IPOS/SEOS on lab.",
|
||||
),
|
||||
"generic": VendorLldpProfile(
|
||||
key="generic",
|
||||
lldp_command="show lldp neighbors",
|
||||
notes="Fallback when device_type/vendor unknown.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def resolve_vendor_key(vendor: str = "", device_type: str = "") -> str:
|
||||
"""Map inventory device_type (preferred) or vendor label -> profile key."""
|
||||
dtype = str(device_type or "").strip().lower()
|
||||
if dtype:
|
||||
for prefix, key in _DEVICE_TYPE_PREFIX_RULES:
|
||||
if dtype == prefix.rstrip("_") or dtype.startswith(prefix):
|
||||
return key
|
||||
if dtype in VENDOR_LLDP_PROFILES and dtype != "generic":
|
||||
return dtype
|
||||
|
||||
label = str(vendor or "").strip().lower()
|
||||
if label:
|
||||
if label in _VENDOR_LABEL_TO_KEY:
|
||||
return _VENDOR_LABEL_TO_KEY[label]
|
||||
for token, key in _VENDOR_LABEL_TO_KEY.items():
|
||||
if label == token or label.startswith(f"{token} ") or label.startswith(f"{token}-"):
|
||||
return key
|
||||
return "generic"
|
||||
|
||||
|
||||
def get_vendor_profile(vendor: str = "", device_type: str = "") -> VendorLldpProfile:
|
||||
key = resolve_vendor_key(vendor, device_type)
|
||||
return VENDOR_LLDP_PROFILES.get(key) or VENDOR_LLDP_PROFILES["generic"]
|
||||
|
||||
|
||||
def lldp_command_for_vendor(vendor: str = "", device_type: str = "") -> str:
|
||||
return get_vendor_profile(vendor, device_type).lldp_command
|
||||
|
||||
|
||||
def cdp_command_for_vendor(vendor: str = "", device_type: str = "") -> str:
|
||||
return get_vendor_profile(vendor, device_type).cdp_command
|
||||
|
||||
|
||||
def pick_neighbor_command(
|
||||
*,
|
||||
protocol: str = "auto",
|
||||
vendor: str = "",
|
||||
device_type: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""Return (command, protocol_tag).
|
||||
|
||||
Default/auto uses LLDP for all vendors (multi-vendor fabrics).
|
||||
Pass protocol=\"cdp\" only when explicitly requesting CDP (Cisco).
|
||||
"""
|
||||
proto = str(protocol or "auto").strip().lower()
|
||||
profile = get_vendor_profile(vendor, device_type)
|
||||
if proto == "cdp":
|
||||
cmd = profile.cdp_command or "show cdp neighbors detail"
|
||||
return cmd, "cdp"
|
||||
return profile.lldp_command, "lldp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsers — keep working ones; stubs return [] until lab echoes are added
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_cisco_lldp(text: str) -> list[NeighborHit]:
|
||||
"""Cisco `show lldp neighbors detail` (preferred); brief table as fallback."""
|
||||
hits = _parse_cisco_lldp_detail(text)
|
||||
if hits:
|
||||
return hits
|
||||
return _parse_lldp_brief_table(text)
|
||||
|
||||
|
||||
def parse_cisco_cdp(text: str) -> list[NeighborHit]:
|
||||
"""Cisco `show cdp neighbors detail`."""
|
||||
return _parse_cdp_detail(text)
|
||||
|
||||
|
||||
def parse_huawei_lldp(text: str) -> list[NeighborHit]:
|
||||
"""Huawei `display lldp neighbor`."""
|
||||
hits = _parse_huawei_lldp_neighbor(text)
|
||||
if hits:
|
||||
return hits
|
||||
return _parse_lldp_brief_table(text)
|
||||
|
||||
|
||||
def parse_h3c_lldp(text: str) -> list[NeighborHit]:
|
||||
"""H3C Comware LLDP — placeholder until lab echo is captured."""
|
||||
# TODO: replace with Comware-specific parser using real `display lldp ...` output.
|
||||
_ = text
|
||||
return []
|
||||
|
||||
|
||||
def parse_zte_lldp(text: str) -> list[NeighborHit]:
|
||||
"""ZTE ZXROS LLDP — placeholder; brief table may work as interim."""
|
||||
# TODO: replace with ZXROS-specific parser using lab echo.
|
||||
return _parse_lldp_brief_table(text)
|
||||
|
||||
|
||||
def parse_juniper_lldp(text: str) -> list[NeighborHit]:
|
||||
"""Juniper Junos LLDP — placeholder until lab echo is captured."""
|
||||
# TODO: parse `show lldp neighbors` / detail from Junos sample.
|
||||
_ = text
|
||||
return []
|
||||
|
||||
|
||||
def parse_nokia_lldp(text: str) -> list[NeighborHit]:
|
||||
"""Nokia SROS/SRL LLDP — placeholder until lab echo is captured."""
|
||||
# TODO: parse `show system lldp neighbor` (SROS) / SRL equivalent.
|
||||
_ = text
|
||||
return []
|
||||
|
||||
|
||||
def parse_ericsson_lldp(text: str) -> list[NeighborHit]:
|
||||
"""Ericsson IPOS/SEOS LLDP — placeholder until lab echo is captured."""
|
||||
# TODO: parse vendor show output from lab.
|
||||
_ = text
|
||||
return []
|
||||
|
||||
|
||||
def parse_generic_lldp(text: str) -> list[NeighborHit]:
|
||||
"""Best-effort fallback when vendor is unknown."""
|
||||
hits = _parse_cisco_lldp_detail(text)
|
||||
if hits:
|
||||
return hits
|
||||
hits = _parse_huawei_lldp_neighbor(text)
|
||||
if hits:
|
||||
return hits
|
||||
return _parse_lldp_brief_table(text)
|
||||
|
||||
|
||||
_VENDOR_PARSERS: dict[str, ParserFn] = {
|
||||
"cisco": parse_cisco_lldp,
|
||||
"huawei": parse_huawei_lldp,
|
||||
"h3c": parse_h3c_lldp,
|
||||
"zte": parse_zte_lldp,
|
||||
"juniper": parse_juniper_lldp,
|
||||
"nokia": parse_nokia_lldp,
|
||||
"ericsson": parse_ericsson_lldp,
|
||||
"generic": parse_generic_lldp,
|
||||
}
|
||||
|
||||
|
||||
def parse_neighbor_output(
|
||||
text: str,
|
||||
*,
|
||||
protocol: str = "lldp",
|
||||
vendor: str = "",
|
||||
device_type: str = "",
|
||||
) -> list[NeighborHit]:
|
||||
"""Parse neighbor CLI output using the device_type/vendor-specific parser."""
|
||||
raw = str(text or "")
|
||||
if not raw.strip():
|
||||
return []
|
||||
proto = str(protocol or "lldp").strip().lower()
|
||||
key = resolve_vendor_key(vendor, device_type)
|
||||
|
||||
if proto == "cdp":
|
||||
return parse_cisco_cdp(raw)
|
||||
|
||||
parser = _VENDOR_PARSERS.get(key) or parse_generic_lldp
|
||||
hits = parser(raw)
|
||||
if hits:
|
||||
return hits
|
||||
|
||||
# Soft fallbacks so an early/wrong tag still yields something useful.
|
||||
if key != "cisco":
|
||||
hits = parse_cisco_lldp(raw)
|
||||
if hits:
|
||||
return hits
|
||||
if key != "huawei":
|
||||
hits = parse_huawei_lldp(raw)
|
||||
if hits:
|
||||
return hits
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared low-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]:
|
||||
"""Cisco IOS `show lldp neighbors detail` blocks starting at Local Intf."""
|
||||
raw = str(text or "")
|
||||
if not re.search(r"(?i)Local\s+Intf\s*:", raw):
|
||||
return []
|
||||
chunks = re.split(r"(?i)(?=Local\s+Intf\s*:)", raw)
|
||||
hits: list[NeighborHit] = []
|
||||
for chunk in chunks:
|
||||
if not re.search(r"(?i)Local\s+Intf\s*:", chunk):
|
||||
continue
|
||||
local_port = _kv(chunk, r"Local\s+Intf\s*:\s*(.+)")
|
||||
remote_port = _kv(chunk, r"Port\s+id\s*:\s*(.+)")
|
||||
sys_name = _kv(chunk, r"System\s+Name\s*:\s*(.+)")
|
||||
# Prefer IPv4 under Management Addresses; skip OID / MAC "Other:" lines.
|
||||
ip = ""
|
||||
m = re.search(
|
||||
r"(?is)Management\s+Addresses?\s*:(.*?)(?:\n\s*\n|Auto Negotiation|Total entries|$)",
|
||||
chunk,
|
||||
)
|
||||
if m:
|
||||
mgmt_lines = []
|
||||
for ln in (m.group(1) or "").splitlines():
|
||||
low = ln.lower()
|
||||
if "oid" in low or re.search(r"(?i)^\s*other\s*:", ln):
|
||||
continue
|
||||
mgmt_lines.append(ln)
|
||||
ip_m = _IPV4_RE.search("\n".join(mgmt_lines))
|
||||
if ip_m:
|
||||
ip = ip_m.group(0)
|
||||
if not sys_name and not remote_port and not local_port:
|
||||
continue
|
||||
# Skip empty / not-advertised system names
|
||||
name = (sys_name or "").strip()
|
||||
if name.lower() in {"", "-", "not advertised"}:
|
||||
name = ""
|
||||
hits.append(
|
||||
NeighborHit(
|
||||
remote_name=name,
|
||||
remote_ip=ip,
|
||||
local_port=(local_port or "").strip(),
|
||||
remote_port=(remote_port or "").strip(),
|
||||
protocol="lldp",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
def _parse_lldp_brief_table(text: str) -> list[NeighborHit]:
|
||||
"""Cisco/ZTE-style brief table: Device ID / Local Intf / ... / Port ID."""
|
||||
lines = [ln.rstrip() for ln in str(text or "").splitlines()]
|
||||
start = -1
|
||||
for i, ln in enumerate(lines):
|
||||
low = ln.lower()
|
||||
if "device id" in low and ("local" in low or "intf" in low or "port" in low):
|
||||
start = i + 1
|
||||
break
|
||||
if "system name" in low and "local" in low:
|
||||
start = i + 1
|
||||
break
|
||||
if start < 0:
|
||||
return []
|
||||
hits: list[NeighborHit] = []
|
||||
for ln in lines[start:]:
|
||||
s = ln.strip()
|
||||
if not s or set(s) <= {"-", "="}:
|
||||
continue
|
||||
if s.lower().startswith(("total", "capability", "---")):
|
||||
continue
|
||||
parts = s.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
remote = parts[0]
|
||||
local_port = parts[1] if len(parts) >= 2 else ""
|
||||
remote_port = parts[-1] if len(parts) >= 4 else ""
|
||||
if remote.lower() in {"device", "system", "chassis"}:
|
||||
continue
|
||||
hits.append(
|
||||
NeighborHit(
|
||||
remote_name=remote,
|
||||
local_port=local_port,
|
||||
remote_port=remote_port,
|
||||
protocol="lldp",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
def _parse_huawei_lldp_neighbor(text: str) -> list[NeighborHit]:
|
||||
"""Huawei VRP `display lldp neighbor` — per-interface sections."""
|
||||
raw = str(text or "")
|
||||
# Split on "<ifname> has N neighbor(s):"
|
||||
header_re = re.compile(
|
||||
r"(?im)^(\S+)\s+has\s+(\d+)\s+neighbor\(s\)\s*:\s*$"
|
||||
)
|
||||
hits: list[NeighborHit] = []
|
||||
matches = list(header_re.finditer(raw))
|
||||
if not matches:
|
||||
# Older compact sample with Local Interface: field
|
||||
return _parse_huawei_lldp_blocks_legacy(raw)
|
||||
|
||||
for i, m in enumerate(matches):
|
||||
local_if = m.group(1).strip()
|
||||
count = int(m.group(2))
|
||||
if count <= 0:
|
||||
continue
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw)
|
||||
section = raw[start:end]
|
||||
# One section may contain multiple neighbors; split on Neighbor index
|
||||
sub_chunks = re.split(r"(?im)(?=^Neighbor\s+index\s*:)", section)
|
||||
for chunk in sub_chunks:
|
||||
if not re.search(r"(?i)Neighbor\s+index\s*:", chunk):
|
||||
# Sometimes fields appear without explicit index; still try once.
|
||||
if not re.search(r"(?i)System\s+name\s*:", chunk):
|
||||
continue
|
||||
sys_name = _kv(chunk, r"System\s+name\s*:\s*(.+)")
|
||||
port_id = _kv(chunk, r"Port\s+ID\s*:\s*(.+)")
|
||||
mgmt = _kv(chunk, r"Management\s+address\s*:\s*(.+)")
|
||||
ip = ""
|
||||
if mgmt:
|
||||
ip_m = _IPV4_RE.search(mgmt)
|
||||
if ip_m:
|
||||
ip = ip_m.group(0)
|
||||
name = (sys_name or "").strip()
|
||||
# Hostname may be FQDN — keep as-is; matcher strips domain.
|
||||
if not name and not port_id and not ip:
|
||||
continue
|
||||
hits.append(
|
||||
NeighborHit(
|
||||
remote_name=name,
|
||||
remote_ip=ip,
|
||||
local_port=local_if,
|
||||
remote_port=(port_id or "").strip(),
|
||||
protocol="lldp",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
def _parse_huawei_lldp_blocks_legacy(text: str) -> list[NeighborHit]:
|
||||
"""Older/compact Huawei block with Local Interface field."""
|
||||
hits: list[NeighborHit] = []
|
||||
blocks = re.split(r"\n\s*\n", str(text or ""))
|
||||
for block in blocks:
|
||||
if not block.strip():
|
||||
continue
|
||||
sys_name = _kv(block, r"System\s+name\s*[:=]\s*(.+)")
|
||||
local_if = _kv(block, r"Local\s+(?:Interface|Port)\s*[:=]\s*(.+)")
|
||||
port_id = _kv(block, r"Port\s+ID\s*[:=]\s*(.+)")
|
||||
mgmt = _kv(block, r"Management\s+address\s*[:=]\s*(.+)")
|
||||
if not sys_name and not port_id:
|
||||
continue
|
||||
ip = ""
|
||||
if mgmt:
|
||||
m = _IPV4_RE.search(mgmt)
|
||||
if m:
|
||||
ip = m.group(0)
|
||||
hits.append(
|
||||
NeighborHit(
|
||||
remote_name=(sys_name or "").strip(),
|
||||
remote_ip=ip,
|
||||
local_port=(local_if or "").strip(),
|
||||
remote_port=(port_id or "").strip(),
|
||||
protocol="lldp",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
def _parse_cdp_detail(text: str) -> list[NeighborHit]:
|
||||
"""Cisco `show cdp neighbors detail`."""
|
||||
hits: list[NeighborHit] = []
|
||||
chunks = re.split(r"(?i)\n(?=Device ID\s*:)", str(text or ""))
|
||||
for chunk in chunks:
|
||||
if not re.search(r"(?i)Device\s+ID\s*:", chunk):
|
||||
continue
|
||||
device_id = _kv(chunk, r"Device\s+ID\s*:\s*(.+)")
|
||||
ip = ""
|
||||
ip_line = _kv(chunk, r"IP(?:v4)?\s+address\s*:\s*(.+)")
|
||||
if ip_line:
|
||||
m = _IPV4_RE.search(ip_line)
|
||||
if m:
|
||||
ip = m.group(0)
|
||||
local_port = _kv(chunk, r"Interface\s*:\s*([^,\n]+)")
|
||||
remote_port = _kv(chunk, r"Port ID\s*(?:\(outgoing port\))?\s*:\s*(.+)")
|
||||
if not device_id and not ip:
|
||||
continue
|
||||
hits.append(
|
||||
NeighborHit(
|
||||
remote_name=(device_id or "").strip(),
|
||||
remote_ip=ip,
|
||||
local_port=(local_port or "").strip().rstrip(","),
|
||||
remote_port=(remote_port or "").strip(),
|
||||
protocol="cdp",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
def _kv(text: str, pattern: str) -> str:
|
||||
m = re.search(pattern, text, flags=re.IGNORECASE | re.MULTILINE)
|
||||
if not m:
|
||||
return ""
|
||||
return str(m.group(1) or "").strip()
|
||||
71
netx_api/topology_router.py
Normal file
71
netx_api/topology_router.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Topology HTTP routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .topology_schemas import (
|
||||
TopologyDiscoverRequest,
|
||||
TopologyGraphPut,
|
||||
TopologyMapCreate,
|
||||
TopologyMapUpdate,
|
||||
)
|
||||
from .topology_service import (
|
||||
create_map,
|
||||
delete_map,
|
||||
discover_neighbors,
|
||||
get_graph,
|
||||
list_maps,
|
||||
put_graph,
|
||||
update_map,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/v1/topology", tags=["topology"])
|
||||
|
||||
|
||||
@router.get("/maps")
|
||||
def api_list_maps(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return list_maps(db)
|
||||
|
||||
|
||||
@router.post("/maps")
|
||||
def api_create_map(body: TopologyMapCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return create_map(db, body).model_dump()
|
||||
|
||||
|
||||
@router.get("/maps/{map_id}")
|
||||
def api_get_map(map_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return get_graph(db, map_id).model_dump()
|
||||
|
||||
|
||||
@router.patch("/maps/{map_id}")
|
||||
def api_patch_map(
|
||||
map_id: str, body: TopologyMapUpdate, db: Session = Depends(get_db)
|
||||
) -> dict[str, Any]:
|
||||
return update_map(db, map_id, body).model_dump()
|
||||
|
||||
|
||||
@router.delete("/maps/{map_id}")
|
||||
def api_delete_map(map_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return delete_map(db, map_id)
|
||||
|
||||
|
||||
@router.put("/maps/{map_id}/graph")
|
||||
def api_put_graph(
|
||||
map_id: str, body: TopologyGraphPut, db: Session = Depends(get_db)
|
||||
) -> dict[str, Any]:
|
||||
return put_graph(db, map_id, body).model_dump()
|
||||
|
||||
|
||||
@router.post("/maps/{map_id}/discover")
|
||||
def api_discover(
|
||||
map_id: str,
|
||||
body: TopologyDiscoverRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
req = body or TopologyDiscoverRequest()
|
||||
return discover_neighbors(db, map_id, req).model_dump()
|
||||
112
netx_api/topology_schemas.py
Normal file
112
netx_api/topology_schemas.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Pydantic schemas for topology maps / nodes / edges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TopologyMapCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=256)
|
||||
remark: str = Field(default="", max_length=1024)
|
||||
|
||||
|
||||
class TopologyMapUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
remark: str | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class TopologyMapOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
remark: str
|
||||
node_count: int = 0
|
||||
edge_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TopologyNodeIn(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
managed_ne_id: str = ""
|
||||
ume_ne_id: str = ""
|
||||
label: str = ""
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
|
||||
|
||||
class TopologyEdgeIn(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
source_node_id: str = Field(min_length=1, max_length=64)
|
||||
target_node_id: str = Field(min_length=1, max_length=64)
|
||||
source_port: str = ""
|
||||
target_port: str = ""
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class TopologyNodeOut(BaseModel):
|
||||
id: str
|
||||
map_id: str
|
||||
managed_ne_id: str = ""
|
||||
ume_ne_id: str = ""
|
||||
label: str = ""
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
ne_name: str = ""
|
||||
ne_ip: str = ""
|
||||
vendor: str = ""
|
||||
protocol: str = ""
|
||||
connect_status: str = ""
|
||||
|
||||
|
||||
class TopologyEdgeOut(BaseModel):
|
||||
id: str
|
||||
map_id: str
|
||||
source_node_id: str
|
||||
target_node_id: str
|
||||
source_port: str = ""
|
||||
target_port: str = ""
|
||||
source: str = "manual"
|
||||
discovered_at: datetime | None = None
|
||||
|
||||
|
||||
class TopologyGraphOut(BaseModel):
|
||||
map: TopologyMapOut
|
||||
nodes: list[TopologyNodeOut]
|
||||
edges: list[TopologyEdgeOut]
|
||||
|
||||
|
||||
class TopologyGraphPut(BaseModel):
|
||||
nodes: list[TopologyNodeIn] = Field(default_factory=list)
|
||||
edges: list[TopologyEdgeIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TopologyDiscoverRequest(BaseModel):
|
||||
"""Run LLDP/CDP discovery for managed NEs currently on the map."""
|
||||
|
||||
protocol: str = Field(default="auto", description="auto | lldp | cdp")
|
||||
ne_ids: list[str] | None = None
|
||||
|
||||
|
||||
class TopologyDiscoverNeResult(BaseModel):
|
||||
ne_id: str
|
||||
ne_name: str = ""
|
||||
ne_ip: str = ""
|
||||
ok: bool = False
|
||||
command: str = ""
|
||||
neighbors: int = 0
|
||||
edges_added: int = 0
|
||||
edges_updated: int = 0
|
||||
error: str = ""
|
||||
raw_preview: str = ""
|
||||
|
||||
|
||||
class TopologyDiscoverOut(BaseModel):
|
||||
map_id: str
|
||||
protocol: str
|
||||
scanned: int = 0
|
||||
edges_added: int = 0
|
||||
edges_updated: int = 0
|
||||
results: list[TopologyDiscoverNeResult] = Field(default_factory=list)
|
||||
graph: TopologyGraphOut | None = None
|
||||
420
netx_api/topology_service.py
Normal file
420
netx_api/topology_service.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""Topology map CRUD, graph save, and LLDP/CDP edge discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode
|
||||
from .ne_exec import execute_managed_ne_commands
|
||||
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
|
||||
from .topology_schemas import (
|
||||
TopologyDiscoverNeResult,
|
||||
TopologyDiscoverOut,
|
||||
TopologyDiscoverRequest,
|
||||
TopologyEdgeIn,
|
||||
TopologyEdgeOut,
|
||||
TopologyGraphOut,
|
||||
TopologyGraphPut,
|
||||
TopologyMapCreate,
|
||||
TopologyMapOut,
|
||||
TopologyMapUpdate,
|
||||
TopologyNodeIn,
|
||||
TopologyNodeOut,
|
||||
)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def _map_out(m: TopologyMap, *, node_count: int = 0, edge_count: int = 0) -> TopologyMapOut:
|
||||
return TopologyMapOut(
|
||||
id=m.id,
|
||||
name=m.name,
|
||||
remark=m.remark or "",
|
||||
node_count=node_count,
|
||||
edge_count=edge_count,
|
||||
created_at=m.created_at,
|
||||
updated_at=m.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_map_or_404(db: Session, map_id: str) -> TopologyMap:
|
||||
mid = str(map_id or "").strip()
|
||||
row = db.get(TopologyMap, mid) if mid else None
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="topology_map_not_found")
|
||||
return row
|
||||
|
||||
|
||||
def list_maps(db: Session) -> dict[str, Any]:
|
||||
rows = db.query(TopologyMap).order_by(TopologyMap.updated_at.desc()).all()
|
||||
items: list[TopologyMapOut] = []
|
||||
for m in rows:
|
||||
nc = db.query(TopologyNode).filter(TopologyNode.map_id == m.id).count()
|
||||
ec = db.query(TopologyEdge).filter(TopologyEdge.map_id == m.id).count()
|
||||
items.append(_map_out(m, node_count=nc, edge_count=ec))
|
||||
return {"total": len(items), "items": [i.model_dump() for i in items]}
|
||||
|
||||
|
||||
def create_map(db: Session, body: TopologyMapCreate) -> TopologyMapOut:
|
||||
name = str(body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
now = _utcnow()
|
||||
row = TopologyMap(
|
||||
id=uuid4().hex,
|
||||
name=name[:256],
|
||||
remark=str(body.remark or "")[:1024],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _map_out(row)
|
||||
|
||||
|
||||
def update_map(db: Session, map_id: str, body: TopologyMapUpdate) -> TopologyMapOut:
|
||||
row = _get_map_or_404(db, map_id)
|
||||
if body.name is not None:
|
||||
name = str(body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
row.name = name[:256]
|
||||
if body.remark is not None:
|
||||
row.remark = str(body.remark or "")[:1024]
|
||||
row.updated_at = _utcnow()
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
nc = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).count()
|
||||
ec = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).count()
|
||||
return _map_out(row, node_count=nc, edge_count=ec)
|
||||
|
||||
|
||||
def delete_map(db: Session, map_id: str) -> dict[str, Any]:
|
||||
row = _get_map_or_404(db, map_id)
|
||||
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
|
||||
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"ok": True, "map_id": map_id, "deleted": True}
|
||||
|
||||
|
||||
def _ne_lookup(db: Session, ne_ids: set[str]) -> dict[str, ManagedNE]:
|
||||
if not ne_ids:
|
||||
return {}
|
||||
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(list(ne_ids))).all()
|
||||
return {r.id: r for r in rows}
|
||||
|
||||
|
||||
def _node_out(n: TopologyNode, ne: ManagedNE | None) -> TopologyNodeOut:
|
||||
label = (n.label or "").strip()
|
||||
if not label and ne is not None:
|
||||
label = (ne.name or ne.ip_address or n.id)[:256]
|
||||
return TopologyNodeOut(
|
||||
id=n.id,
|
||||
map_id=n.map_id,
|
||||
managed_ne_id=n.managed_ne_id or "",
|
||||
ume_ne_id=n.ume_ne_id or "",
|
||||
label=label,
|
||||
x=float(n.x or 0),
|
||||
y=float(n.y or 0),
|
||||
ne_name=(ne.name if ne else ""),
|
||||
ne_ip=(ne.ip_address if ne else ""),
|
||||
vendor=(ne.vendor if ne else ""),
|
||||
protocol=(ne.protocol if ne else ""),
|
||||
connect_status=(ne.connect_status if ne else ""),
|
||||
)
|
||||
|
||||
|
||||
def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
|
||||
return TopologyEdgeOut(
|
||||
id=e.id,
|
||||
map_id=e.map_id,
|
||||
source_node_id=e.source_node_id,
|
||||
target_node_id=e.target_node_id,
|
||||
source_port=e.source_port or "",
|
||||
target_port=e.target_port or "",
|
||||
source=e.source or "manual",
|
||||
discovered_at=e.discovered_at,
|
||||
)
|
||||
|
||||
|
||||
def get_graph(db: Session, map_id: str) -> TopologyGraphOut:
|
||||
row = _get_map_or_404(db, map_id)
|
||||
nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
|
||||
edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
|
||||
nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id})
|
||||
return TopologyGraphOut(
|
||||
map=_map_out(row, node_count=len(nodes), edge_count=len(edges)),
|
||||
nodes=[_node_out(n, nes.get(str(n.managed_ne_id or ""))) for n in nodes],
|
||||
edges=[_edge_out(e) for e in edges],
|
||||
)
|
||||
|
||||
|
||||
def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraphOut:
|
||||
row = _get_map_or_404(db, map_id)
|
||||
nodes_in = list(body.nodes or [])
|
||||
edges_in = list(body.edges or [])
|
||||
if len(nodes_in) > 2000:
|
||||
raise HTTPException(status_code=400, detail="too_many_nodes")
|
||||
if len(edges_in) > 5000:
|
||||
raise HTTPException(status_code=400, detail="too_many_edges")
|
||||
|
||||
node_ids = set()
|
||||
for n in nodes_in:
|
||||
nid = str(n.id or "").strip()
|
||||
if not nid:
|
||||
raise HTTPException(status_code=400, detail="node_id_required")
|
||||
if nid in node_ids:
|
||||
raise HTTPException(status_code=400, detail=f"duplicate_node_id:{nid}")
|
||||
node_ids.add(nid)
|
||||
|
||||
for e in edges_in:
|
||||
sid = str(e.source_node_id or "").strip()
|
||||
tid = str(e.target_node_id or "").strip()
|
||||
if sid not in node_ids or tid not in node_ids:
|
||||
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
|
||||
src = str(e.source or "manual").strip().lower() or "manual"
|
||||
if src not in {"manual", "lldp", "cdp"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_edge_source")
|
||||
|
||||
now = _utcnow()
|
||||
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
|
||||
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
|
||||
|
||||
for n in nodes_in:
|
||||
db.add(
|
||||
TopologyNode(
|
||||
id=str(n.id).strip(),
|
||||
map_id=row.id,
|
||||
managed_ne_id=str(n.managed_ne_id or "").strip(),
|
||||
ume_ne_id=str(n.ume_ne_id or "").strip(),
|
||||
label=str(n.label or "").strip()[:256],
|
||||
x=float(n.x or 0),
|
||||
y=float(n.y or 0),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
for e in edges_in:
|
||||
db.add(
|
||||
TopologyEdge(
|
||||
id=str(e.id).strip() or uuid4().hex,
|
||||
map_id=row.id,
|
||||
source_node_id=str(e.source_node_id).strip(),
|
||||
target_node_id=str(e.target_node_id).strip(),
|
||||
source_port=str(e.source_port or "").strip()[:128],
|
||||
target_port=str(e.target_port or "").strip()[:128],
|
||||
source=str(e.source or "manual").strip().lower() or "manual",
|
||||
discovered_at=now if str(e.source or "").lower() in {"lldp", "cdp"} else None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
row.updated_at = now
|
||||
db.commit()
|
||||
return get_graph(db, row.id)
|
||||
|
||||
|
||||
def _norm_key(s: str) -> str:
|
||||
return re_sub_host(str(s or "").strip().lower())
|
||||
|
||||
|
||||
def re_sub_host(s: str) -> str:
|
||||
# Strip domain / trailing punctuation for hostname matching.
|
||||
t = s.split(".")[0].strip().lower()
|
||||
return t.rstrip(".,;:")
|
||||
|
||||
|
||||
def _match_neighbor_to_node(
|
||||
hit: NeighborHit,
|
||||
*,
|
||||
nodes: list[TopologyNode],
|
||||
nes: dict[str, ManagedNE],
|
||||
self_node_id: str,
|
||||
) -> TopologyNode | None:
|
||||
name_key = _norm_key(hit.remote_name)
|
||||
ip_key = str(hit.remote_ip or "").strip()
|
||||
for n in nodes:
|
||||
if n.id == self_node_id:
|
||||
continue
|
||||
ne = nes.get(str(n.managed_ne_id or ""))
|
||||
candidates = [
|
||||
_norm_key(n.label or ""),
|
||||
_norm_key(ne.name if ne else ""),
|
||||
str(ne.ip_address if ne else "").strip(),
|
||||
]
|
||||
if ip_key and ip_key in candidates:
|
||||
return n
|
||||
if name_key and name_key in {_norm_key(c) for c in candidates if c}:
|
||||
return n
|
||||
# Also match managed NE name without case
|
||||
if ne and name_key and name_key == _norm_key(ne.name):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
|
||||
if a <= b:
|
||||
return (a, b, local_port, remote_port)
|
||||
return (b, a, remote_port, local_port)
|
||||
|
||||
|
||||
def discover_neighbors(
|
||||
db: Session,
|
||||
map_id: str,
|
||||
body: TopologyDiscoverRequest,
|
||||
) -> TopologyDiscoverOut:
|
||||
row = _get_map_or_404(db, map_id)
|
||||
nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
|
||||
edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
|
||||
nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id})
|
||||
|
||||
filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()}
|
||||
scan_nodes = [
|
||||
n
|
||||
for n in nodes
|
||||
if n.managed_ne_id
|
||||
and n.managed_ne_id in nes
|
||||
and (not filter_ids or n.managed_ne_id in filter_ids)
|
||||
]
|
||||
|
||||
# Index existing edges for upsert (undirected + ports).
|
||||
existing: dict[tuple[str, str, str, str], TopologyEdge] = {}
|
||||
for e in edges:
|
||||
key = _edge_pair_key(
|
||||
e.source_node_id,
|
||||
e.target_node_id,
|
||||
(e.source_port or "").strip(),
|
||||
(e.target_port or "").strip(),
|
||||
)
|
||||
existing[key] = e
|
||||
|
||||
results: list[TopologyDiscoverNeResult] = []
|
||||
added = 0
|
||||
updated = 0
|
||||
now = _utcnow()
|
||||
proto_req = str(body.protocol or "auto").strip().lower() or "auto"
|
||||
|
||||
for n in scan_nodes:
|
||||
ne = nes.get(n.managed_ne_id)
|
||||
if ne is None:
|
||||
continue
|
||||
cmd, proto_tag = pick_neighbor_command(
|
||||
protocol=proto_req,
|
||||
vendor=ne.vendor or "",
|
||||
device_type=ne.device_type or "",
|
||||
)
|
||||
if not cmd:
|
||||
results.append(
|
||||
TopologyDiscoverNeResult(
|
||||
ne_id=ne.id,
|
||||
ne_name=ne.name or "",
|
||||
ne_ip=ne.ip_address or "",
|
||||
ok=False,
|
||||
error="no_command_for_vendor",
|
||||
)
|
||||
)
|
||||
continue
|
||||
exec_out = execute_managed_ne_commands(
|
||||
db,
|
||||
[cmd],
|
||||
ne_id=ne.id,
|
||||
read_timeout_sec=60,
|
||||
)
|
||||
if not exec_out.get("ok"):
|
||||
results.append(
|
||||
TopologyDiscoverNeResult(
|
||||
ne_id=ne.id,
|
||||
ne_name=ne.name or "",
|
||||
ne_ip=ne.ip_address or "",
|
||||
ok=False,
|
||||
command=cmd,
|
||||
error=str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
raw = str(exec_out.get("output") or "")
|
||||
hits = parse_neighbor_output(
|
||||
raw,
|
||||
protocol=proto_tag,
|
||||
vendor=ne.vendor or "",
|
||||
device_type=ne.device_type or "",
|
||||
)
|
||||
ne_added = 0
|
||||
ne_updated = 0
|
||||
for hit in hits:
|
||||
peer = _match_neighbor_to_node(
|
||||
hit, nodes=nodes, nes=nes, self_node_id=n.id
|
||||
)
|
||||
if peer is None:
|
||||
continue
|
||||
local_port = (hit.local_port or "").strip()[:128]
|
||||
remote_port = (hit.remote_port or "").strip()[:128]
|
||||
key = _edge_pair_key(n.id, peer.id, local_port, remote_port)
|
||||
edge_proto = hit.protocol if hit.protocol in {"lldp", "cdp"} else proto_tag
|
||||
cur = existing.get(key)
|
||||
if cur is not None:
|
||||
# Never downgrade manual edges; refresh discovery metadata only for discovered.
|
||||
if (cur.source or "manual") == "manual":
|
||||
continue
|
||||
cur.source = edge_proto
|
||||
cur.source_port = local_port if cur.source_node_id == n.id else remote_port
|
||||
cur.target_port = remote_port if cur.source_node_id == n.id else local_port
|
||||
cur.discovered_at = now
|
||||
cur.updated_at = now
|
||||
ne_updated += 1
|
||||
updated += 1
|
||||
continue
|
||||
# Prefer orientation: scanning node as source.
|
||||
new_edge = TopologyEdge(
|
||||
id=uuid4().hex,
|
||||
map_id=row.id,
|
||||
source_node_id=n.id,
|
||||
target_node_id=peer.id,
|
||||
source_port=local_port,
|
||||
target_port=remote_port,
|
||||
source=edge_proto,
|
||||
discovered_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(new_edge)
|
||||
existing[key] = new_edge
|
||||
ne_added += 1
|
||||
added += 1
|
||||
|
||||
results.append(
|
||||
TopologyDiscoverNeResult(
|
||||
ne_id=ne.id,
|
||||
ne_name=ne.name or "",
|
||||
ne_ip=ne.ip_address or "",
|
||||
ok=True,
|
||||
command=cmd,
|
||||
neighbors=len(hits),
|
||||
edges_added=ne_added,
|
||||
edges_updated=ne_updated,
|
||||
raw_preview=raw[:800],
|
||||
)
|
||||
)
|
||||
|
||||
row.updated_at = now
|
||||
db.commit()
|
||||
graph = get_graph(db, row.id)
|
||||
return TopologyDiscoverOut(
|
||||
map_id=row.id,
|
||||
protocol=proto_req,
|
||||
scanned=len(results),
|
||||
edges_added=added,
|
||||
edges_updated=updated,
|
||||
results=results,
|
||||
graph=graph,
|
||||
)
|
||||
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",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
|
|
@ -881,6 +882,55 @@
|
|||
"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": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
|
|
@ -916,7 +966,7 @@
|
|||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -926,7 +976,7 @@
|
|||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
|
@ -1216,6 +1266,48 @@
|
|||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
|
|
@ -1347,6 +1439,12 @@
|
|||
],
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -1386,9 +1484,114 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2696,6 +2899,15 @@
|
|||
"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": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
|
|
@ -2842,6 +3054,34 @@
|
|||
"peerDependencies": {
|
||||
"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",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CollectPage } from "./pages/CollectPage";
|
|||
import { NePage } from "./pages/NePage";
|
||||
import { UmePage } from "./pages/UmePage";
|
||||
import { WebcrtPage } from "./pages/WebcrtPage";
|
||||
import { TopologyPage } from "./pages/TopologyPage";
|
||||
import { fetchIntegrationStatus } from "./services/api";
|
||||
|
||||
function App() {
|
||||
|
|
@ -51,6 +52,7 @@ function App() {
|
|||
<Route path="/ne" element={<NePage />} />
|
||||
<Route path="/collect" element={<CollectPage />} />
|
||||
<Route path="/webcrt" element={<WebcrtPage />} />
|
||||
<Route path="/topology" element={<TopologyPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
iconTone: "slate",
|
||||
titleKey: "layout.titleWebcrt",
|
||||
},
|
||||
{
|
||||
moduleId: "topology",
|
||||
path: "/topology",
|
||||
section: "operations",
|
||||
labelKey: "workbench.cards.topology",
|
||||
descKey: "workbench.cards.topologyDesc",
|
||||
iconTone: "blue",
|
||||
titleKey: "layout.titleTopology",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||
|
|
|
|||
|
|
@ -46,4 +46,6 @@ export const queryKeys = {
|
|||
page: number,
|
||||
pageSize: number,
|
||||
) => ["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",
|
||||
webcrt: "WebCRT",
|
||||
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
|
||||
topology: "Topology",
|
||||
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
|
|
@ -36,6 +38,7 @@ const en = {
|
|||
titleManagedNe: "NE Management",
|
||||
titleCollect: "Batch Collect",
|
||||
titleWebcrt: "WebCRT",
|
||||
titleTopology: "Topology",
|
||||
navUme: "UME",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -588,6 +591,49 @@ const en = {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const zh = {
|
|||
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
|
||||
webcrt: "WebCRT",
|
||||
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
|
||||
topology: "拓扑管理",
|
||||
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
|
|
@ -36,6 +38,7 @@ const zh = {
|
|||
titleManagedNe: "网元管理",
|
||||
titleCollect: "批量采集",
|
||||
titleWebcrt: "WebCRT",
|
||||
titleTopology: "拓扑管理",
|
||||
navUme: "UME 对接",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -586,6 +589,49 @@ const zh = {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1836,3 +1836,380 @@ pre {
|
|||
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,
|
||||
CliTargetListResponse,
|
||||
UmeCliOverrideItem,
|
||||
TopologyDiscoverOut,
|
||||
TopologyGraph,
|
||||
TopologyMapItem,
|
||||
} from "../types";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 fetchUmeAlarmSubscriptionStatus = () =>
|
||||
|
|
@ -411,3 +425,51 @@ export const fetchUmeCurrentAlarms = (params: {
|
|||
`/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?: 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