Add topology maps with LLDP discovery and React Flow canvas.

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

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

View file

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

View file

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

497
netx_api/topology_lldp.py Normal file
View file

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

View file

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

View file

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

View file

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

364
tests/test_topology.py Normal file
View 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
View file

@ -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
}
}
}
}
}

View file

@ -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"

View file

@ -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>

View file

@ -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 {

View file

@ -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,
};

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View 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>
);
}

View file

@ -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 || {},
);

View file

@ -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;
};