From dc805c20867e191996e85aa34e81cd3b0be72e5a Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 29 Jul 2026 23:53:06 +0800 Subject: [PATCH] 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 --- netx_api/main.py | 2 + netx_api/models.py | 48 ++- netx_api/topology_lldp.py | 497 +++++++++++++++++++++++ netx_api/topology_router.py | 71 ++++ netx_api/topology_schemas.py | 112 +++++ netx_api/topology_service.py | 420 +++++++++++++++++++ tests/test_topology.py | 364 +++++++++++++++++ web/package-lock.json | 246 ++++++++++- web/package.json | 1 + web/src/App.tsx | 2 + web/src/config/modules.ts | 9 + web/src/constants/queryKeys.ts | 2 + web/src/i18n/en.ts | 46 +++ web/src/i18n/zh.ts | 46 +++ web/src/index.css | 377 +++++++++++++++++ web/src/pages/TopologyPage.tsx | 718 +++++++++++++++++++++++++++++++++ web/src/services/api.ts | 62 +++ web/src/types.ts | 65 +++ 18 files changed, 3084 insertions(+), 4 deletions(-) create mode 100644 netx_api/topology_lldp.py create mode 100644 netx_api/topology_router.py create mode 100644 netx_api/topology_schemas.py create mode 100644 netx_api/topology_service.py create mode 100644 tests/test_topology.py create mode 100644 web/src/pages/TopologyPage.tsx diff --git a/netx_api/main.py b/netx_api/main.py index d528c7f..0eaa141 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -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(), diff --git a/netx_api/models.py b/netx_api/models.py index aea6c03..1aa467e 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -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) diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py new file mode 100644 index 0000000..cc70a44 --- /dev/null +++ b/netx_api/topology_lldp.py @@ -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"(? 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 " 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() diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py new file mode 100644 index 0000000..f8e48c4 --- /dev/null +++ b/netx_api/topology_router.py @@ -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() diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py new file mode 100644 index 0000000..3f2eb9b --- /dev/null +++ b/netx_api/topology_schemas.py @@ -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 diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py new file mode 100644 index 0000000..2ec6cf3 --- /dev/null +++ b/netx_api/topology_service.py @@ -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, + ) diff --git a/tests/test_topology.py b/tests/test_topology.py new file mode 100644 index 0000000..0bb48de --- /dev/null +++ b/tests/test_topology.py @@ -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() diff --git a/web/package-lock.json b/web/package-lock.json index c4267f2..83f76a5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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 + } + } } } } diff --git a/web/package.json b/web/package.json index 46be69a..560323c 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/src/App.tsx b/web/src/App.tsx index 99fba39..e79d13f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index d127bcb..6d19833 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -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 { diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 55e9ad4..0d4f489 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -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, }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 20c7ce0..1717406 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 76877b8..1bc1889 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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; diff --git a/web/src/index.css b/web/src/index.css index c3c1610..5597635 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; + } +} diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx new file mode 100644 index 0000000..db5ecf9 --- /dev/null +++ b/web/src/pages/TopologyPage.tsx @@ -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({ + 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 ( + + ); +} + +function NeNode({ data, selected }: NodeProps>) { + 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 ( +
+
+ {/* Centered on the disc so edges meet the circular icon, not the caption. */} + + + +
+
{bits.join(" · ")}
+
+ ); +} + +const nodeTypes = { neNode: NeNode }; + +function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) { + const rfNodes: Node[] = 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[], 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(""); + const [keyword, setKeyword] = useState(""); + const [selectedNodeId, setSelectedNodeId] = useState(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>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const rfRef = useRef, 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 ( +
+ + +
+
+
+
+ {maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")} + {mapId ? ( + + ) : null} +
+ {t("topology.canvasHint")} +
+
+
+ + + + +
+ + + +
+
+ + {selectedNode ? ( +
+ + {t("topology.selected")}: {selectedNode.data.label} + {selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""} + +
+ + + +
+
+ ) : null} + +
+ {mapId ? ( + + { + 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"]} + > + + + + + + ) : ( +
{t("topology.selectMap")}
+ )} +
+
+
+ ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 96b4415..aaabcd6 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -18,6 +18,9 @@ import type { CliMeta, CliTargetListResponse, UmeCliOverrideItem, + TopologyDiscoverOut, + TopologyGraph, + TopologyMapItem, } from "../types"; const parseApiResponse = async (res: Response): Promise> => { @@ -66,6 +69,17 @@ export const apiDelete = async (path: string): Promise => { return data as T; }; +export const apiPut = async (path: string, body: unknown): Promise => { + 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("/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("/v1/topology/maps", body); + +export const fetchTopologyGraph = (mapId: string) => + apiGet(`/v1/topology/maps/${encodeURIComponent(mapId)}`); + +export const updateTopologyMap = (mapId: string, body: { name?: string; remark?: string }) => + apiPatch(`/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(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body); + +export const discoverTopologyNeighbors = ( + mapId: string, + body?: { protocol?: string; ne_ids?: string[] }, +) => + apiPost( + `/v1/topology/maps/${encodeURIComponent(mapId)}/discover`, + body || {}, + ); diff --git a/web/src/types.ts b/web/src/types.ts index 1415d20..c6f3120 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; +};