From d05ba0f1f758ff22803638a6474f4dd4020245f0 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 03:39:15 +0800 Subject: [PATCH] Add LLDP collect page with conservative fabric edge lifecycle. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move scheduled discovery under Network → LLDP links, mark absent edges missing after one successful scan, purge only after four miss cycles, and expose unmatched/raw job detail. Co-authored-by: Cursor --- docs/MCP.md | 7 +- netx_api/config.py | 5 + netx_api/device_types.py | 3 + netx_api/lldp_collect_router.py | 57 + netx_api/lldp_collect_scheduler.py | 96 + netx_api/lldp_collect_schemas.py | 65 + netx_api/lldp_collect_service.py | 234 ++ netx_api/main.py | 18 +- netx_api/models.py | 183 +- netx_api/topology_lldp.py | 19 +- netx_api/topology_migrate.py | 64 + netx_api/topology_router.py | 259 +- netx_api/topology_schemas.py | 277 ++- netx_api/topology_service.py | 2273 +++++++++++++----- packages/netx-mcp/README.md | 6 +- packages/netx-mcp/src/netx_mcp/http_tools.py | 175 +- tests/test_lldp_collect.py | 65 + tests/test_topology.py | 1121 ++++----- web/WEB.md | 19 +- web/src/App.tsx | 4 +- web/src/config/networkNav.ts | 14 +- web/src/constants/queryKeys.ts | 13 +- web/src/i18n/en.ts | 50 +- web/src/i18n/zh.ts | 50 +- web/src/index.css | 16 + web/src/pages/ConfigSyncPage.tsx | 20 +- web/src/pages/TopologyPage.tsx | 480 ++-- web/src/pages/network/LldpLinksPage.tsx | 581 +++++ web/src/services/api.ts | 246 +- web/src/types.ts | 163 +- 30 files changed, 4713 insertions(+), 1870 deletions(-) create mode 100644 netx_api/lldp_collect_router.py create mode 100644 netx_api/lldp_collect_scheduler.py create mode 100644 netx_api/lldp_collect_schemas.py create mode 100644 netx_api/lldp_collect_service.py create mode 100644 netx_api/topology_migrate.py create mode 100644 tests/test_lldp_collect.py create mode 100644 web/src/pages/network/LldpLinksPage.tsx diff --git a/docs/MCP.md b/docs/MCP.md index b35b67d..656b90e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -123,16 +123,17 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx --- -## 5. 暴露的 12 个工具 +## 5. 暴露的工具 | 类别 | 工具名 | |------|--------| | UME 告警 | `queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics` | | UME 网元 | `queryUmeNeInventory`, `getUmeNe` | | UME 原始/SQL | `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` | -| 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe` | +| 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` | +| 拓扑 Fabric | `getTopologySummary`, `queryTopologyNodes`, `queryTopologyEdges`, `getTopologyNeighborhood`, `runLldpDiscover`, `getLldpDiscoverJob`, `listTopologyViews`, `getTopologyView` | -oclaw 中名称带前缀:`mcp__netx__`。 +物理拓扑仅 LLDP;分页查询,勿默认拉全图。oclaw 中名称带前缀:`mcp__netx__`。 --- diff --git a/netx_api/config.py b/netx_api/config.py index ec66b27..e69b099 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -77,6 +77,11 @@ class Settings(BaseSettings): config_sync_scheduler_tick_sec: int = 60 # After process start / unexpected restart, wait before any scheduled sync. config_sync_startup_grace_sec: int = 3600 + # Fabric LLDP collect (network topology management). + # Scheduler thread may run, but policy.enabled defaults False — no collect until operator turns it on. + lldp_collect_scheduler_enabled: bool = True + lldp_collect_scheduler_tick_sec: int = 60 + lldp_collect_startup_grace_sec: int = 3600 # Port traffic monitoring (CLI rate bit/s samples) port_traffic_scheduler_enabled: bool = True port_traffic_scheduler_tick_sec: int = 15 diff --git a/netx_api/device_types.py b/netx_api/device_types.py index 773bfc7..d6d87b4 100644 --- a/netx_api/device_types.py +++ b/netx_api/device_types.py @@ -49,3 +49,6 @@ WEBCRT_DEVICE_TYPES: tuple[str, ...] = SUPPORTED_DEVICE_TYPES + ("linux", "gener # ManagedNE.source value for sessions created via WebCRT Quick Connect. WEBCRT_NE_SOURCE = "webcrt" + +# ManagedNE.source for LLDP-discovered peers not yet in inventory (SSH shell, empty creds). +LLDP_DISCOVERED_NE_SOURCE = "lldp" diff --git a/netx_api/lldp_collect_router.py b/netx_api/lldp_collect_router.py new file mode 100644 index 0000000..ba6887c --- /dev/null +++ b/netx_api/lldp_collect_router.py @@ -0,0 +1,57 @@ +"""HTTP routes for network-management LLDP link collect.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from .db import get_db +from .lldp_collect_schemas import LldpCollectPolicyUpdate +from .lldp_collect_service import ( + get_dashboard, + get_job_detail, + get_policy, + list_jobs, + start_collect, + update_policy, +) + +router = APIRouter(prefix="/v1/topology/lldp-collect", tags=["topology-lldp-collect"]) + + +@router.get("/policy") +def api_get_policy(db: Session = Depends(get_db)) -> dict[str, Any]: + return get_policy(db).model_dump() + + +@router.put("/policy") +def api_put_policy( + body: LldpCollectPolicyUpdate, db: Session = Depends(get_db) +) -> dict[str, Any]: + return update_policy(db, body).model_dump() + + +@router.get("/dashboard") +def api_dashboard(db: Session = Depends(get_db)) -> dict[str, Any]: + return get_dashboard(db).model_dump() + + +@router.post("/start") +def api_start(db: Session = Depends(get_db)) -> dict[str, Any]: + return start_collect(db, trigger_mode="manual") + + +@router.get("/jobs") +def api_list_jobs( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> dict[str, Any]: + return list_jobs(db, page=page, page_size=page_size) + + +@router.get("/jobs/{job_id}") +def api_get_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + return get_job_detail(db, job_id) diff --git a/netx_api/lldp_collect_scheduler.py b/netx_api/lldp_collect_scheduler.py new file mode 100644 index 0000000..109fa0f --- /dev/null +++ b/netx_api/lldp_collect_scheduler.py @@ -0,0 +1,96 @@ +"""Background scheduler for periodic fabric LLDP collect.""" + +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime + +from .config import settings +from .db import SessionLocal +from .lldp_collect_service import ( + ensure_policy, + has_running_job, + next_due_at, + start_collect, +) + +_log = logging.getLogger("netx.lldp_collect.scheduler") +_stop = threading.Event() +_thread: threading.Thread | None = None +_BOOT_MONO = time.monotonic() + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def startup_grace_remaining_sec() -> float: + grace = max(0, int(getattr(settings, "lldp_collect_startup_grace_sec", 3600) or 0)) + elapsed = time.monotonic() - _BOOT_MONO + return max(0.0, float(grace) - elapsed) + + +def in_startup_grace() -> bool: + return startup_grace_remaining_sec() > 0 + + +def try_start_scheduled_collect() -> str | None: + if not bool(getattr(settings, "lldp_collect_scheduler_enabled", True)): + return None + if in_startup_grace(): + return None + db = SessionLocal() + try: + policy = ensure_policy(db) + if not policy.enabled: + return None + if has_running_job(db) is not None: + return None + due = next_due_at(db, policy) + if due is not None and due > _utcnow(): + return None + out = start_collect(db, trigger_mode="schedule") + job_id = str((out.get("job") or {}).get("id") or "") + _log.info("lldp_collect scheduled job started id=%s", job_id) + return job_id or None + except Exception as exc: # noqa: BLE001 + db.rollback() + detail = getattr(exc, "detail", None) + if detail in {"no_selected_targets", "lldp_collect_already_running"}: + _log.info("lldp_collect schedule skip: %s", detail) + return None + _log.exception("lldp_collect schedule start failed") + return None + finally: + db.close() + + +def _loop() -> None: + tick = max(15, int(getattr(settings, "lldp_collect_scheduler_tick_sec", 60) or 60)) + grace = max(0, int(getattr(settings, "lldp_collect_startup_grace_sec", 3600) or 0)) + _log.info("lldp_collect scheduler started tick=%ss startup_grace=%ss", tick, grace) + while not _stop.is_set(): + try: + try_start_scheduled_collect() + except Exception: + _log.exception("lldp_collect scheduler tick failed") + _stop.wait(tick) + _log.info("lldp_collect scheduler stopped") + + +def start_lldp_collect_scheduler() -> None: + global _thread + if not bool(getattr(settings, "lldp_collect_scheduler_enabled", True)): + _log.info("lldp_collect scheduler disabled by settings") + return + if _thread is not None and _thread.is_alive(): + return + _stop.clear() + _thread = threading.Thread(target=_loop, name="lldp-collect-scheduler", daemon=True) + _thread.start() + + +def stop_lldp_collect_scheduler() -> None: + _stop.set() diff --git a/netx_api/lldp_collect_schemas.py b/netx_api/lldp_collect_schemas.py new file mode 100644 index 0000000..eeedb6d --- /dev/null +++ b/netx_api/lldp_collect_schemas.py @@ -0,0 +1,65 @@ +"""Schemas for network-management LLDP link collect (policy + dashboard).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class LldpCollectTargetRef(BaseModel): + source: str = "managed" # managed | ume + id: str + + +class LldpCollectPolicyOut(BaseModel): + enabled: bool = False + interval_days: int = 1 + concurrency: int = 4 + scope_mode: str = "all" + selected_targets: list[LldpCollectTargetRef] = Field(default_factory=list) + auto_add_unmatched: bool = True + updated_at: datetime | None = None + + +class LldpCollectPolicyUpdate(BaseModel): + enabled: bool | None = None + interval_days: int | None = Field(default=None, ge=1, le=365) + concurrency: int | None = Field(default=None, ge=1, le=32) + scope_mode: str | None = None + selected_targets: list[LldpCollectTargetRef] | None = None + auto_add_unmatched: bool | None = None + + +class LldpCollectJobSummary(BaseModel): + id: str + scope: str = "" + trigger_mode: str = "manual" + status: str = "" + total: int = 0 + done: int = 0 + edges_added: int = 0 + edges_updated: int = 0 + edges_stale: int = 0 + error: str = "" + started_at: datetime | None = None + ended_at: datetime | None = None + created_at: datetime | None = None + + +class LldpCollectDashboardOut(BaseModel): + policy: LldpCollectPolicyOut + fabric_node_count: int = 0 + fabric_edge_count: int = 0 + fabric_edge_active: int = 0 + fabric_edge_stale: int = 0 + last_discover_at: datetime | None = None + running_job: LldpCollectJobSummary | None = None + last_job: LldpCollectJobSummary | None = None + next_due_at: datetime | None = None + + +class LldpCollectStartOut(BaseModel): + ok: bool = True + job: dict[str, Any] = Field(default_factory=dict) diff --git a/netx_api/lldp_collect_service.py b/netx_api/lldp_collect_service.py new file mode 100644 index 0000000..5e0f27c --- /dev/null +++ b/netx_api/lldp_collect_service.py @@ -0,0 +1,234 @@ +"""LLDP collect policy + dashboard (network topology management).""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from .lldp_collect_schemas import ( + LldpCollectDashboardOut, + LldpCollectJobSummary, + LldpCollectPolicyOut, + LldpCollectPolicyUpdate, + LldpCollectTargetRef, +) +from .models import LldpCollectPolicy, TopoDiscoverJob, TopoFabricStats +from .topology_schemas import FabricDiscoverRequest +from .topology_service import get_discover_job, start_discover_job + +POLICY_ID = 1 + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def ensure_policy(db: Session) -> LldpCollectPolicy: + row = db.get(LldpCollectPolicy, POLICY_ID) + if row is None: + row = LldpCollectPolicy( + id=POLICY_ID, + enabled=False, + interval_days=1, + concurrency=4, + scope_mode="all", + selected_targets=[], + auto_add_unmatched=True, + updated_at=_utcnow(), + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def _policy_out(row: LldpCollectPolicy) -> LldpCollectPolicyOut: + refs: list[LldpCollectTargetRef] = [] + for raw in row.selected_targets or []: + if not isinstance(raw, dict): + continue + tid = str(raw.get("id") or "").strip() + if not tid: + continue + src = str(raw.get("source") or "managed").strip().lower() or "managed" + if src not in {"managed", "ume"}: + src = "managed" + refs.append(LldpCollectTargetRef(source=src, id=tid)) + return LldpCollectPolicyOut( + enabled=bool(row.enabled), + interval_days=int(row.interval_days or 1), + concurrency=int(row.concurrency or 4), + scope_mode="selected" if str(row.scope_mode or "") == "selected" else "all", + selected_targets=refs, + auto_add_unmatched=bool(row.auto_add_unmatched), + updated_at=row.updated_at, + ) + + +def get_policy(db: Session) -> LldpCollectPolicyOut: + return _policy_out(ensure_policy(db)) + + +def update_policy(db: Session, body: LldpCollectPolicyUpdate) -> LldpCollectPolicyOut: + row = ensure_policy(db) + data = body.model_dump(exclude_unset=True) + if "enabled" in data and data["enabled"] is not None: + row.enabled = bool(data["enabled"]) + if "interval_days" in data and data["interval_days"] is not None: + row.interval_days = max(1, min(365, int(data["interval_days"]))) + if "concurrency" in data and data["concurrency"] is not None: + row.concurrency = max(1, min(32, int(data["concurrency"]))) + if "scope_mode" in data and data["scope_mode"] is not None: + mode = str(data["scope_mode"] or "").strip().lower() + if mode not in {"all", "selected"}: + raise HTTPException(status_code=400, detail="invalid_scope_mode") + row.scope_mode = mode + if "selected_targets" in data and data["selected_targets"] is not None: + cleaned: list[dict[str, str]] = [] + for ref in data["selected_targets"] or []: + if isinstance(ref, LldpCollectTargetRef): + tid = ref.id.strip() + src = ref.source.strip().lower() or "managed" + elif isinstance(ref, dict): + tid = str(ref.get("id") or "").strip() + src = str(ref.get("source") or "managed").strip().lower() or "managed" + else: + continue + if not tid: + continue + if src not in {"managed", "ume"}: + src = "managed" + cleaned.append({"source": src, "id": tid}) + row.selected_targets = cleaned + if "auto_add_unmatched" in data and data["auto_add_unmatched"] is not None: + row.auto_add_unmatched = bool(data["auto_add_unmatched"]) + row.updated_at = _utcnow() + db.commit() + db.refresh(row) + return _policy_out(row) + + +def _job_summary(job: TopoDiscoverJob | None) -> LldpCollectJobSummary | None: + if job is None: + return None + return LldpCollectJobSummary( + id=job.id, + scope=job.scope or "", + trigger_mode=getattr(job, "trigger_mode", None) or "manual", + status=job.status or "", + total=int(job.total or 0), + done=int(job.done or 0), + edges_added=int(job.edges_added or 0), + edges_updated=int(job.edges_updated or 0), + edges_stale=int(job.edges_stale or 0), + error=job.error or "", + started_at=job.started_at, + ended_at=job.ended_at, + created_at=job.created_at, + ) + + +def has_running_job(db: Session) -> TopoDiscoverJob | None: + return ( + db.query(TopoDiscoverJob) + .filter(TopoDiscoverJob.status.in_(["pending", "running"])) + .order_by(TopoDiscoverJob.created_at.desc()) + .first() + ) + + +def last_finished_job(db: Session) -> TopoDiscoverJob | None: + return ( + db.query(TopoDiscoverJob) + .filter(TopoDiscoverJob.status.in_(["done", "failed"])) + .order_by(TopoDiscoverJob.created_at.desc()) + .first() + ) + + +def next_due_at(db: Session, policy: LldpCollectPolicy) -> datetime | None: + if not policy.enabled: + return None + days = max(1, int(policy.interval_days or 1)) + last = ( + db.query(TopoDiscoverJob) + .filter(TopoDiscoverJob.status == "done", TopoDiscoverJob.ended_at.isnot(None)) + .order_by(TopoDiscoverJob.ended_at.desc()) + .first() + ) + if last is None or last.ended_at is None: + return _utcnow() + return last.ended_at + timedelta(days=days) + + +def build_discover_request(policy: LldpCollectPolicy) -> FabricDiscoverRequest: + concurrency = max(1, min(32, int(policy.concurrency or 4))) + auto_add = bool(policy.auto_add_unmatched) + if str(policy.scope_mode or "") == "selected": + ne_ids: list[str] = [] + for raw in policy.selected_targets or []: + if isinstance(raw, dict): + tid = str(raw.get("id") or "").strip() + if tid: + ne_ids.append(tid) + if not ne_ids: + raise HTTPException(status_code=400, detail="no_selected_targets") + return FabricDiscoverRequest( + scope="ne_ids", + ne_ids=ne_ids, + concurrency=concurrency, + auto_add_unmatched=auto_add, + ) + return FabricDiscoverRequest( + scope="all_inventory", + ne_ids=[], + concurrency=concurrency, + auto_add_unmatched=auto_add, + ) + + +def start_collect(db: Session, *, trigger_mode: str = "manual") -> dict: + if has_running_job(db) is not None: + raise HTTPException(status_code=409, detail="lldp_collect_already_running") + policy = ensure_policy(db) + body = build_discover_request(policy) + job = start_discover_job(db, body, trigger_mode=trigger_mode) + return {"ok": True, "job": job.model_dump()} + + +def get_dashboard(db: Session) -> LldpCollectDashboardOut: + policy = ensure_policy(db) + stats = db.get(TopoFabricStats, "global") + running = has_running_job(db) + last = last_finished_job(db) + return LldpCollectDashboardOut( + policy=_policy_out(policy), + fabric_node_count=int(stats.node_count if stats else 0), + fabric_edge_count=int(stats.edge_count if stats else 0), + fabric_edge_active=int(stats.edge_active if stats else 0), + fabric_edge_stale=int(stats.edge_stale if stats else 0), + last_discover_at=stats.last_discover_at if stats else None, + running_job=_job_summary(running), + last_job=_job_summary(last), + next_due_at=next_due_at(db, policy), + ) + + +def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict: + page = max(1, int(page or 1)) + page_size = max(1, min(100, int(page_size or 20))) + q = db.query(TopoDiscoverJob).order_by(TopoDiscoverJob.created_at.desc()) + total = int(q.count()) + rows = q.offset((page - 1) * page_size).limit(page_size).all() + return { + "total": total, + "page": page, + "page_size": page_size, + "items": [_job_summary(r).model_dump() for r in rows if _job_summary(r)], + } + + +def get_job_detail(db: Session, job_id: str) -> dict: + return get_discover_job(db, job_id).model_dump() diff --git a/netx_api/main.py b/netx_api/main.py index 056dc8c..51b1e3d 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -31,6 +31,7 @@ from .port_traffic_router import router as port_traffic_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 .lldp_collect_router import router as lldp_collect_router from .importer import aggregate_alarms, import_alarm_excel, query_alarms from .models import ( AiAnalyzeHistory, @@ -139,6 +140,7 @@ app.include_router(config_sync_router) app.include_router(port_traffic_router) app.include_router(webcrt_router) app.include_router(topology_router) +app.include_router(lldp_collect_router) parser_cfg = load_parser_config() _UME_CLIENT_SINGLETON = UMEClient( token_loader=lambda: load_shared_token(), @@ -814,10 +816,12 @@ def on_startup() -> None: ) conn.exec_driver_sql("ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP") from .port_traffic_migrate import ensure_port_traffic_series_schema + from .topology_migrate import ensure_topology_schema ensure_port_traffic_series_schema(conn) + ensure_topology_schema(conn) except Exception: - _schedule_log.exception("startup: auth/port_traffic schema migration failed") + _schedule_log.exception("startup: auth/port_traffic/topology schema migration failed") _reset_runtime_pause_flags() _fail_stale_running_sync_jobs_on_startup() if _needs_startup_alarm_sync_before_ws(): @@ -847,6 +851,12 @@ def on_startup() -> None: cfg_resumed = recover_config_sync_on_startup(db) if cfg_resumed: _schedule_log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed) + try: + from .lldp_collect_service import ensure_policy as ensure_lldp_collect_policy + + ensure_lldp_collect_policy(db) + except Exception: + _schedule_log.exception("startup: lldp_collect policy ensure failed") pt_cleared = recover_port_traffic_on_startup(db) if pt_cleared: _schedule_log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", pt_cleared) @@ -866,6 +876,12 @@ def on_startup() -> None: start_config_sync_scheduler() except Exception: _schedule_log.exception("startup: config_sync scheduler init failed") + try: + from .lldp_collect_scheduler import start_lldp_collect_scheduler + + start_lldp_collect_scheduler() + except Exception: + _schedule_log.exception("startup: lldp_collect scheduler init failed") try: from .port_traffic_scheduler import start_port_traffic_scheduler diff --git a/netx_api/models.py b/netx_api/models.py index 4f8a8d5..9abdc9a 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -393,57 +393,188 @@ class NeCollectionRun(Base): ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) -class TopologyMap(Base): - """Named topology canvas (document-style graph).""" +class TopoFabricNode(Base): + """Global topology fabric node (inventory-aligned). Target scale ~50k.""" - __tablename__ = "topology_map" + __tablename__ = "topo_fabric_node" + __table_args__ = ( + UniqueConstraint("managed_ne_id", name="uq_topo_fabric_node_managed_ne_id"), + UniqueConstraint("ume_ne_id", name="uq_topo_fabric_node_ume_ne_id"), + ) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + managed_ne_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ume_ne_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) name: Mapped[str] = mapped_column(String(256), default="", index=True) - remark: Mapped[str] = mapped_column(String(1024), default="") + ip: Mapped[str] = mapped_column(String(128), default="", index=True) + vendor: Mapped[str] = mapped_column(String(64), default="") + device_type: Mapped[str] = mapped_column(String(64), default="") + attrs: Mapped[dict] = mapped_column(_JsonType, default=dict) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) 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.""" +class TopoFabricEdge(Base): + """Global fabric link. Target scale ~1M; layer reserved for future BGP/tunnel/l2vpn.""" - __tablename__ = "topology_node" + __tablename__ = "topo_fabric_edge" + __table_args__ = ( + UniqueConstraint( + "layer", + "a_node_id", + "b_node_id", + "a_port", + "b_port", + name="uq_topo_fabric_edge_endpoints", + ), + ) 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) + # physical | bgp | tunnel | l2vpn (P1 writes physical only) + layer: Mapped[str] = mapped_column(String(32), default="physical", index=True) + a_node_id: Mapped[str] = mapped_column(String(64), index=True) + b_node_id: Mapped[str] = mapped_column(String(64), index=True) + a_port: Mapped[str] = mapped_column(String(128), default="") + b_port: Mapped[str] = mapped_column(String(128), default="") + # lldp | manual | stale + source: Mapped[str] = mapped_column(String(32), default="lldp", index=True) + # active | stale + status: Mapped[str] = mapped_column(String(32), default="active", index=True) + attrs: Mapped[dict] = mapped_column(_JsonType, default=dict) + discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) 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.""" +class TopoView(Base): + """Named topology view (presentation); replaces legacy topology_map.""" - __tablename__ = "topology_edge" + __tablename__ = "topo_view" 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 | stale - source: Mapped[str] = mapped_column(String(32), default="manual", index=True) - # Optional visual overrides; empty / 0 = use provenance defaults. + name: Mapped[str] = mapped_column(String(256), default="", index=True) + remark: Mapped[str] = mapped_column(String(1024), default="") + # { node_ids?: [], layer?: "physical", status?: "active", keyword?: "" } + filter: Mapped[dict] = mapped_column(_JsonType, default=dict) + viewport: Mapped[dict] = mapped_column(_JsonType, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class TopoViewNode(Base): + """Node placement on a view; no link facts.""" + + __tablename__ = "topo_view_node" + __table_args__ = (UniqueConstraint("view_id", "fabric_node_id", name="uq_topo_view_node"),) + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + view_id: Mapped[str] = mapped_column(String(64), index=True) + fabric_node_id: Mapped[str] = mapped_column(String(64), index=True) + x: Mapped[float] = mapped_column(Float, default=0.0) + y: Mapped[float] = mapped_column(Float, default=0.0) + label: Mapped[str] = mapped_column(String(256), default="") + locked: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class TopoViewEdgeStyle(Base): + """Optional per-view edge style override.""" + + __tablename__ = "topo_view_edge_style" + __table_args__ = (UniqueConstraint("view_id", "fabric_edge_id", name="uq_topo_view_edge_style"),) + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + view_id: Mapped[str] = mapped_column(String(64), index=True) + fabric_edge_id: Mapped[str] = mapped_column(String(64), index=True) stroke_color: Mapped[str] = mapped_column(String(32), default="") stroke_width: Mapped[int] = mapped_column(Integer, default=0) - # "" | solid | dashed | dotted line_style: Mapped[str] = mapped_column(String(16), default="") - 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) +class LldpCollectPolicy(Base): + """Singleton policy for periodic fabric LLDP collect (id=1).""" + + __tablename__ = "lldp_collect_policy" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + interval_days: Mapped[int] = mapped_column(Integer, default=1) + concurrency: Mapped[int] = mapped_column(Integer, default=4) + scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected + selected_targets: Mapped[list] = mapped_column(_JsonType, default=list) + auto_add_unmatched: Mapped[bool] = mapped_column(Boolean, default=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class TopoDiscoverJob(Base): + """Async LLDP discovery job over inventory / NE ids.""" + + __tablename__ = "topo_discover_job" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + # all_inventory | ne_ids + scope: Mapped[str] = mapped_column(String(32), default="ne_ids", index=True) + # manual | schedule | topology (ad-hoc from canvas) + trigger_mode: Mapped[str] = mapped_column(String(32), default="manual", index=True) + ne_ids_json: Mapped[list] = mapped_column(_JsonType, default=list) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + total: Mapped[int] = mapped_column(Integer, default=0) + done: Mapped[int] = mapped_column(Integer, default=0) + edges_added: Mapped[int] = mapped_column(Integer, default=0) + edges_updated: Mapped[int] = mapped_column(Integer, default=0) + edges_stale: Mapped[int] = mapped_column(Integer, default=0) + error: 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) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class TopoDiscoverJobItem(Base): + """Per-NE result row for a discover job.""" + + __tablename__ = "topo_discover_job_item" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + job_id: Mapped[str] = mapped_column(String(64), index=True) + ne_id: Mapped[str] = mapped_column(String(128), default="", index=True) + ume_ne_id: Mapped[str] = mapped_column(String(128), default="") + fabric_node_id: Mapped[str] = mapped_column(String(64), default="", index=True) + ne_name: Mapped[str] = mapped_column(String(256), default="") + ne_ip: Mapped[str] = mapped_column(String(128), default="") + ok: Mapped[bool] = mapped_column(Boolean, default=False) + command: Mapped[str] = mapped_column(String(256), default="") + neighbors: Mapped[int] = mapped_column(Integer, default=0) + edges_added: Mapped[int] = mapped_column(Integer, default=0) + edges_updated: Mapped[int] = mapped_column(Integer, default=0) + unmatched_count: Mapped[int] = mapped_column(Integer, default=0) + unmatched_json: Mapped[list] = mapped_column(_JsonType, default=list) + parser_key: Mapped[str] = mapped_column(String(64), default="") + parser_stub: Mapped[bool] = mapped_column(Boolean, default=False) + error: Mapped[str] = mapped_column(String(1024), default="") + raw_preview: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class TopoFabricStats(Base): + """Cached fabric counters for summary (avoid COUNT on 1M edges).""" + + __tablename__ = "topo_fabric_stats" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default="global") + node_count: Mapped[int] = mapped_column(Integer, default=0) + edge_count: Mapped[int] = mapped_column(Integer, default=0) + edge_active: Mapped[int] = mapped_column(Integer, default=0) + edge_stale: Mapped[int] = mapped_column(Integer, default=0) + last_discover_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + class AppUser(Base): """Local netx application user (login account).""" diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py index 2affdfc..ff4cb06 100644 --- a/netx_api/topology_lldp.py +++ b/netx_api/topology_lldp.py @@ -147,25 +147,19 @@ def lldp_command_for_vendor(vendor: str = "", device_type: str = "") -> str: def cdp_command_for_vendor(vendor: str = "", device_type: str = "") -> str: + """Deprecated: CDP is not used for fabric discovery (LLDP only).""" return get_vendor_profile(vendor, device_type).cdp_command def pick_neighbor_command( *, - protocol: str = "auto", + protocol: str = "lldp", 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() + """Return (lldp_command, \"lldp\"). Physical discovery is LLDP-only (CDP ignored).""" + _ = protocol # accepted for call-site compat; always LLDP 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" @@ -274,12 +268,9 @@ def parse_neighbor_output( raw = str(text or "") if not raw.strip(): return [] - proto = str(protocol or "lldp").strip().lower() + _ = protocol # CDP discovery removed; always parse as LLDP 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: diff --git a/netx_api/topology_migrate.py b/netx_api/topology_migrate.py new file mode 100644 index 0000000..492659c --- /dev/null +++ b/netx_api/topology_migrate.py @@ -0,0 +1,64 @@ +"""Startup helpers for final topology schema (drop legacy map tables).""" + +from __future__ import annotations + +import logging + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +_log = logging.getLogger("netx.topology.migrate") + +_LEGACY_TABLES = ("topology_edge", "topology_node", "topology_map") + + +def drop_legacy_topology_tables(conn: Connection) -> None: + """Remove document-style topology_* tables after cutover to fabric/view.""" + dialect = str(getattr(conn.dialect, "name", "") or "").lower() + for table in _LEGACY_TABLES: + try: + if dialect.startswith("postgres"): + conn.execute(text(f'DROP TABLE IF EXISTS "{table}" CASCADE')) + else: + conn.execute(text(f"DROP TABLE IF EXISTS {table}")) + except Exception: + _log.exception("drop legacy topology table failed: %s", table) + + +def ensure_topology_schema(conn: Connection) -> None: + drop_legacy_topology_tables(conn) + dialect = str(getattr(conn.dialect, "name", "") or "").lower() + # Best-effort column add for existing DBs (create_all won't alter). + alter_stmts: list[str] = [] + if dialect.startswith("postgres"): + alter_stmts.append( + "ALTER TABLE topo_discover_job ADD COLUMN IF NOT EXISTS trigger_mode VARCHAR(32) DEFAULT 'manual'" + ) + elif dialect.startswith("sqlite"): + # SQLite: ignore if column already exists. + alter_stmts.append( + "ALTER TABLE topo_discover_job ADD COLUMN trigger_mode VARCHAR(32) DEFAULT 'manual'" + ) + for sql in alter_stmts: + try: + conn.execute(text(sql)) + except Exception: + _log.debug("topology alter skipped/failed: %s", sql[:80], exc_info=True) + + if not dialect.startswith("postgres"): + return + stmts = [ + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_layer_a ON topo_fabric_edge (layer, a_node_id)", + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_layer_b ON topo_fabric_edge (layer, b_node_id)", + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_layer_seen ON topo_fabric_edge (layer, last_seen_at)", + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_active ON topo_fabric_edge (layer) WHERE status = 'active'", + "CREATE INDEX IF NOT EXISTS ix_topo_view_node_view ON topo_view_node (view_id)", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_topo_fabric_node_managed_nn ON topo_fabric_node (managed_ne_id) WHERE managed_ne_id IS NOT NULL", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_topo_fabric_node_ume_nn ON topo_fabric_node (ume_ne_id) WHERE ume_ne_id IS NOT NULL", + "CREATE INDEX IF NOT EXISTS ix_topo_discover_job_trigger ON topo_discover_job (trigger_mode)", + ] + for sql in stmts: + try: + conn.execute(text(sql)) + except Exception: + _log.exception("ensure topology index failed: %s", sql[:80]) diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index b7f501e..5881482 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -1,106 +1,203 @@ -"""Topology HTTP routes.""" +"""Topology HTTP routes — fabric + views (final model).""" from __future__ import annotations -import json -from typing import Any, Iterator +from typing import Any -from fastapi import APIRouter, Depends -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from .db import get_db from .topology_schemas import ( - TopologyDiscoverRequest, - TopologyGraphPut, - TopologyMapCreate, - TopologyMapUpdate, + FabricDiscoverRequest, + FabricManualEdgeIn, + TopologyViewCreate, + TopologyViewUpdate, + ViewEdgeStylePatch, + ViewNodesAdd, + ViewPositionsPatch, ) from .topology_service import ( - create_map, - delete_map, - discover_neighbors, - get_graph, - iter_discover_neighbors, - list_maps, - put_graph, - update_map, + add_nodes_to_view, + create_view, + delete_view, + get_discover_job, + get_fabric_neighborhood, + get_fabric_summary, + get_view_graph, + list_fabric_edges, + list_fabric_nodes, + list_views, + merge_duplicate_fabric_nodes, + patch_view_edge_style, + patch_view_positions, + project_fabric_neighbors_to_view, + refresh_fabric_stats, + remove_view_nodes, + start_discover_job, + update_view, + upsert_fabric_edge, ) router = APIRouter(prefix="/v1/topology", tags=["topology"]) -def _sse_pack(event: dict[str, Any]) -> str: - etype = str(event.get("type") or "message") - return f"event: {etype}\ndata: {json.dumps(event, ensure_ascii=False, default=str)}\n\n" +# --- Fabric ----------------------------------------------------------------- -@router.get("/maps") -def api_list_maps(db: Session = Depends(get_db)) -> dict[str, Any]: - return list_maps(db) +@router.get("/fabric/summary") +def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]: + return get_fabric_summary(db).model_dump() -@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, +@router.get("/fabric/nodes") +def api_fabric_nodes( + keyword: str = "", + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=2000), db: Session = Depends(get_db), ) -> dict[str, Any]: - req = body or TopologyDiscoverRequest() - return discover_neighbors(db, map_id, req).model_dump() + return list_fabric_nodes(db, keyword=keyword, page=page, page_size=page_size) -@router.post("/maps/{map_id}/discover/stream") -def api_discover_stream( - map_id: str, - body: TopologyDiscoverRequest | None = None, +@router.get("/fabric/edges") +def api_fabric_edges( + node_id: str = "", + layer: str = "physical", + status: str = "", + source: str = "", + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=2000), db: Session = Depends(get_db), -) -> StreamingResponse: - """SSE stream: start → ne_start/ne_result (per NE) → done.""" - req = body or TopologyDiscoverRequest() - - def generate() -> Iterator[str]: - try: - for event in iter_discover_neighbors(db, map_id, req): - yield _sse_pack(event) - except Exception as exc: # noqa: BLE001 — surface to client then end stream - yield _sse_pack({"type": "error", "detail": str(exc)[:800]}) - - return StreamingResponse( - generate(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, +) -> dict[str, Any]: + return list_fabric_edges( + db, + node_id=node_id, + layer=layer, + status=status, + source=source, + page=page, + page_size=page_size, ) + + +@router.get("/fabric/neighborhood") +def api_fabric_neighborhood( + node_id: str, + depth: int = Query(default=1, ge=1, le=3), + layer: str = "physical", + db: Session = Depends(get_db), +) -> dict[str, Any]: + return get_fabric_neighborhood(db, node_id, depth=depth, layer=layer).model_dump() + + +@router.post("/fabric/edges") +def api_fabric_manual_edge( + body: FabricManualEdgeIn, + db: Session = Depends(get_db), +) -> dict[str, Any]: + edge, action = upsert_fabric_edge( + db, + a_node_id=body.a_node_id, + b_node_id=body.b_node_id, + a_port=body.a_port, + b_port=body.b_port, + source="manual", + ) + db.commit() + refresh_fabric_stats(db) + return {"ok": True, "action": action, "edge": { + "id": edge.id, + "a_node_id": edge.a_node_id, + "b_node_id": edge.b_node_id, + "a_port": edge.a_port, + "b_port": edge.b_port, + "source": edge.source, + "status": edge.status, + }} + + +@router.post("/fabric/discover") +def api_fabric_discover( + body: FabricDiscoverRequest | None = None, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return start_discover_job(db, body or FabricDiscoverRequest()).model_dump() + + +@router.post("/fabric/cleanup-duplicates") +def api_fabric_cleanup_duplicates(db: Session = Depends(get_db)) -> dict[str, Any]: + """Merge duplicate fabric nodes (same managed/ume/name/ip) and retarget edges.""" + result = merge_duplicate_fabric_nodes(db) + return {"ok": True, **result} + + +@router.get("/fabric/discover/{job_id}") +def api_fabric_discover_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + return get_discover_job(db, job_id).model_dump() + + +# --- Views ------------------------------------------------------------------ + + +@router.get("/views") +def api_list_views(db: Session = Depends(get_db)) -> dict[str, Any]: + return list_views(db) + + +@router.post("/views") +def api_create_view(body: TopologyViewCreate, db: Session = Depends(get_db)) -> dict[str, Any]: + return create_view(db, body).model_dump() + + +@router.get("/views/{view_id}") +def api_get_view(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + return get_view_graph(db, view_id).model_dump() + + +@router.patch("/views/{view_id}") +def api_patch_view( + view_id: str, body: TopologyViewUpdate, db: Session = Depends(get_db) +) -> dict[str, Any]: + return update_view(db, view_id, body).model_dump() + + +@router.delete("/views/{view_id}") +def api_delete_view(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + return delete_view(db, view_id) + + +@router.patch("/views/{view_id}/positions") +def api_patch_positions( + view_id: str, body: ViewPositionsPatch, db: Session = Depends(get_db) +) -> dict[str, Any]: + return patch_view_positions(db, view_id, body).model_dump() + + +@router.post("/views/{view_id}/nodes") +def api_add_nodes( + view_id: str, body: ViewNodesAdd, db: Session = Depends(get_db) +) -> dict[str, Any]: + return add_nodes_to_view(db, view_id, body).model_dump() + + +@router.post("/views/{view_id}/project-neighbors") +def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + return project_fabric_neighbors_to_view(db, view_id).model_dump() + + +@router.post("/views/{view_id}/nodes/remove") +def api_remove_nodes( + view_id: str, + body: dict[str, Any], + db: Session = Depends(get_db), +) -> dict[str, Any]: + ids = body.get("fabric_node_ids") if isinstance(body, dict) else None + return remove_view_nodes(db, view_id, list(ids or [])).model_dump() + + +@router.patch("/views/{view_id}/edge-style") +def api_edge_style( + view_id: str, body: ViewEdgeStylePatch, db: Session = Depends(get_db) +) -> dict[str, Any]: + return patch_view_edge_style(db, view_id, body).model_dump() diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index a8a4eba..1bfb89d 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -1,123 +1,86 @@ -"""Pydantic schemas for topology maps / nodes / edges.""" +"""Pydantic schemas for fabric topology + views (final model).""" from __future__ import annotations from datetime import datetime +from typing import Any from pydantic import BaseModel, Field -class TopologyMapCreate(BaseModel): - name: str = Field(min_length=1, max_length=256) - remark: str = Field(default="", max_length=1024) +# --------------------------------------------------------------------------- +# Fabric +# --------------------------------------------------------------------------- -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): +class FabricNodeOut(BaseModel): id: str - name: str - remark: str + managed_ne_id: str = "" + ume_ne_id: str = "" + name: str = "" + ip: str = "" + vendor: str = "" + device_type: str = "" + attrs: dict[str, Any] = Field(default_factory=dict) + last_seen_at: datetime | None = None + + +class FabricEdgeOut(BaseModel): + id: str + layer: str = "physical" + a_node_id: str + b_node_id: str + a_port: str = "" + b_port: str = "" + source: str = "lldp" + status: str = "active" + attrs: dict[str, Any] = Field(default_factory=dict) + discovered_at: datetime | None = None + last_seen_at: datetime | None = None + + +class FabricSummaryOut(BaseModel): node_count: int = 0 edge_count: int = 0 - created_at: datetime | None = None + edge_active: int = 0 + edge_stale: int = 0 + last_discover_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 - created_at: datetime | None = None +class FabricNeighborhoodOut(BaseModel): + center_node_id: str + depth: int = 1 + nodes: list[FabricNodeOut] = Field(default_factory=list) + edges: list[FabricEdgeOut] = Field(default_factory=list) -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" - stroke_color: str = Field(default="", max_length=32) - stroke_width: int = Field(default=0, ge=0, le=12) - line_style: str = Field(default="", max_length=16) - discovered_at: datetime | None = None - created_at: datetime | None = None +class FabricDiscoverRequest(BaseModel): + """Start LLDP discovery into fabric (no CDP).""" + + scope: str = Field(default="ne_ids", description="all_inventory | ne_ids") + ne_ids: list[str] = Field(default_factory=list) + auto_add_unmatched: bool = Field( + default=True, + description="Create SSH placeholder ManagedNEs for LLDP neighbors not in inventory", + ) + concurrency: int = Field(default=4, ge=1, le=32) + trigger_mode: str = Field(default="manual", description="manual | schedule | topology") -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" - stroke_color: str = "" - stroke_width: int = 0 - line_style: str = "" - 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 TopologyDiscoverUnmatched(BaseModel): +class FabricDiscoverUnmatched(BaseModel): remote_name: str = "" remote_ip: str = "" local_port: str = "" remote_port: str = "" -class TopologyDiscoverLink(BaseModel): - peer_node_id: str = "" - peer_ne_id: str = "" - peer_name: str = "" - peer_ip: str = "" - local_port: str = "" - remote_port: str = "" - protocol: str = "" - action: str = "" # added | updated | kept_manual - - -class TopologyDiscoverNeResult(BaseModel): - ne_id: str +class FabricDiscoverJobItemOut(BaseModel): + id: str + job_id: str + ne_id: str = "" + ume_ne_id: str = "" + fabric_node_id: str = "" ne_name: str = "" ne_ip: str = "" ok: bool = False @@ -126,20 +89,130 @@ class TopologyDiscoverNeResult(BaseModel): edges_added: int = 0 edges_updated: int = 0 unmatched_count: int = 0 - unmatched: list[TopologyDiscoverUnmatched] = Field(default_factory=list) - links: list[TopologyDiscoverLink] = Field(default_factory=list) + unmatched: list[FabricDiscoverUnmatched] = Field(default_factory=list) parser_key: str = "" parser_stub: bool = False error: str = "" raw_preview: str = "" -class TopologyDiscoverOut(BaseModel): - map_id: str - protocol: str - scanned: int = 0 +class FabricDiscoverJobOut(BaseModel): + id: str + scope: str + trigger_mode: str = "manual" + status: str + total: int = 0 + done: int = 0 edges_added: int = 0 edges_updated: int = 0 edges_stale: int = 0 - results: list[TopologyDiscoverNeResult] = Field(default_factory=list) - graph: TopologyGraphOut | None = None + error: str = "" + started_at: datetime | None = None + ended_at: datetime | None = None + items: list[FabricDiscoverJobItemOut] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + + +class TopologyViewCreate(BaseModel): + name: str = Field(min_length=1, max_length=256) + remark: str = Field(default="", max_length=1024) + filter: dict[str, Any] = Field(default_factory=dict) + + +class TopologyViewUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=256) + remark: str | None = Field(default=None, max_length=1024) + filter: dict[str, Any] | None = None + viewport: dict[str, Any] | None = None + + +class TopologyViewOut(BaseModel): + id: str + name: str + remark: str = "" + filter: dict[str, Any] = Field(default_factory=dict) + viewport: dict[str, Any] = Field(default_factory=dict) + node_count: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None + + +class ViewNodeIn(BaseModel): + fabric_node_id: str = Field(min_length=1, max_length=64) + x: float = 0.0 + y: float = 0.0 + label: str = "" + locked: bool = False + + +class ViewNodeOut(BaseModel): + fabric_node_id: str + managed_ne_id: str = "" + ume_ne_id: str = "" + label: str = "" + x: float = 0.0 + y: float = 0.0 + locked: bool = False + name: str = "" + ip: str = "" + vendor: str = "" + device_type: str = "" + connect_status: str = "" + + +class ViewEdgeOut(BaseModel): + id: str + a_node_id: str + b_node_id: str + a_port: str = "" + b_port: str = "" + source: str = "lldp" + status: str = "active" + layer: str = "physical" + stroke_color: str = "" + stroke_width: int = 0 + line_style: str = "" + discovered_at: datetime | None = None + + +class TopologyViewGraphOut(BaseModel): + view: TopologyViewOut + nodes: list[ViewNodeOut] + edges: list[ViewEdgeOut] + truncated: bool = False + truncate_reason: str = "" + + +class ViewPositionsPatch(BaseModel): + positions: list[ViewNodeIn] = Field(default_factory=list) + + +class ViewNodesAdd(BaseModel): + """Add inventory NEs onto a view (creates fabric nodes as needed).""" + + managed_ne_ids: list[str] = Field(default_factory=list) + ume_ne_ids: list[str] = Field(default_factory=list) + fabric_node_ids: list[str] = Field( + default_factory=list, + description="Place existing fabric nodes onto the view", + ) + # Optional initial positions keyed by managed/ume id + layout: str = Field(default="grid", description="grid | keep") + + +class ViewEdgeStylePatch(BaseModel): + fabric_edge_id: str + stroke_color: str = "" + stroke_width: int = Field(default=0, ge=0, le=12) + line_style: str = Field(default="", max_length=16) + + +class FabricManualEdgeIn(BaseModel): + a_node_id: str = Field(min_length=1, max_length=64) + b_node_id: str = Field(min_length=1, max_length=64) + a_port: str = "" + b_port: str = "" diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 1b5be91..1de375b 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -1,17 +1,34 @@ -"""Topology map CRUD, graph save, and LLDP/CDP edge discovery.""" +"""Fabric topology + views + LLDP discovery (final model, no CDP).""" from __future__ import annotations +import re +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from typing import Any from uuid import uuid4 -import re from fastapi import HTTPException +from sqlalchemy import func, or_ +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor -from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE +from .db import SessionLocal +from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE +from .models import ( + ManagedNE, + TopoDiscoverJob, + TopoDiscoverJobItem, + TopoFabricEdge, + TopoFabricNode, + TopoFabricStats, + TopoView, + TopoViewEdgeStyle, + TopoViewNode, + UmeInventoryNE, +) from .ne_exec import execute_managed_ne_commands from .topology_lldp import ( NeighborHit, @@ -21,77 +38,610 @@ from .topology_lldp import ( pick_neighbor_command, ) from .topology_schemas import ( - TopologyDiscoverLink, - TopologyDiscoverNeResult, - TopologyDiscoverOut, - TopologyDiscoverRequest, - TopologyDiscoverUnmatched, - TopologyEdgeIn, - TopologyEdgeOut, - TopologyGraphOut, - TopologyGraphPut, - TopologyMapCreate, - TopologyMapOut, - TopologyMapUpdate, - TopologyNodeIn, - TopologyNodeOut, + FabricDiscoverJobItemOut, + FabricDiscoverJobOut, + FabricDiscoverRequest, + FabricDiscoverUnmatched, + FabricEdgeOut, + FabricNeighborhoodOut, + FabricNodeOut, + FabricSummaryOut, + TopologyViewCreate, + TopologyViewGraphOut, + TopologyViewOut, + TopologyViewUpdate, + ViewEdgeOut, + ViewEdgeStylePatch, + ViewNodeIn, + ViewNodeOut, + ViewNodesAdd, + ViewPositionsPatch, ) +PAGE_DEFAULT = 100 +PAGE_MAX = 2000 +VIEW_GRAPH_NODE_HARD_CAP = 2000 +VIEW_GRAPH_EDGE_HARD_CAP = 5000 +_RAW_PREVIEW_MAX = 12_000 +_JOB_LOCK = threading.Lock() +_RUNNING_JOBS: set[str] = set() + +# Fabric link lifecycle: absent once → missing; still absent for N cycles → purge. +_EDGE_STATUS_MISSING = "missing" +_EDGE_STATUS_MISSING_COMPAT = frozenset({"missing", "stale"}) +_MISS_PURGE_AFTER_CYCLES = 4 + + +def _normalize_edge_status(status: str) -> str: + s = str(status or "").strip().lower() or "active" + if s in _EDGE_STATUS_MISSING_COMPAT: + return _EDGE_STATUS_MISSING + return s + + +def _edge_attrs(e: TopoFabricEdge) -> dict[str, Any]: + return dict(e.attrs or {}) + + +def _clear_miss_attrs(attrs: dict[str, Any]) -> dict[str, Any]: + out = dict(attrs or {}) + out.pop("miss_count", None) + out.pop("first_missing_at", None) + out.pop("replaced_by_edge_id", None) + return out + + +def _set_edge_missing( + e: TopoFabricEdge, + now: datetime, + *, + replaced_by_edge_id: str = "", +) -> bool: + """Mark edge missing and bump miss_count. Returns True if newly became missing.""" + prev = _normalize_edge_status(e.status or "") + attrs = _edge_attrs(e) + miss_count = int(attrs.get("miss_count") or 0) + 1 + attrs["miss_count"] = miss_count + if not attrs.get("first_missing_at"): + attrs["first_missing_at"] = now.isoformat(timespec="seconds") + if replaced_by_edge_id: + attrs["replaced_by_edge_id"] = str(replaced_by_edge_id) + e.attrs = attrs + e.status = _EDGE_STATUS_MISSING + # Keep observational source; never leave source stuck on legacy "stale". + if str(e.source or "").strip().lower() in {"", "stale"}: + e.source = "lldp" + e.updated_at = now + return prev != _EDGE_STATUS_MISSING + + +def _purge_edge_if_due(db: Session, e: TopoFabricEdge) -> bool: + """Physically delete missing edge after enough consecutive miss cycles.""" + attrs = _edge_attrs(e) + if int(attrs.get("miss_count") or 0) < _MISS_PURGE_AFTER_CYCLES: + return False + if _normalize_edge_status(e.status or "") != _EDGE_STATUS_MISSING: + return False + if str(e.source or "").strip().lower() == "manual": + return False + db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.fabric_edge_id == e.id).delete( + synchronize_session=False + ) + db.delete(e) + return True + 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 _norm_host(s: str) -> str: + t = str(s or "").strip().lower().split(".")[0] + return t.rstrip(".,;:") + + +def _empty_to_none(s: str | None) -> str | None: + v = str(s or "").strip() + return v or None + + +# --------------------------------------------------------------------------- +# Fabric nodes / edges helpers +# --------------------------------------------------------------------------- + + +def _node_out(n: TopoFabricNode) -> FabricNodeOut: + return FabricNodeOut( + id=n.id, + managed_ne_id=n.managed_ne_id or "", + ume_ne_id=n.ume_ne_id or "", + name=n.name or "", + ip=n.ip or "", + vendor=n.vendor or "", + device_type=n.device_type or "", + attrs=dict(n.attrs or {}), + last_seen_at=n.last_seen_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 +def _edge_out(e: TopoFabricEdge) -> FabricEdgeOut: + src = str(e.source or "lldp").strip().lower() or "lldp" + if src == "stale": + src = "lldp" + return FabricEdgeOut( + id=e.id, + layer=e.layer or "physical", + a_node_id=e.a_node_id, + b_node_id=e.b_node_id, + a_port=e.a_port or "", + b_port=e.b_port or "", + source=src, + status=_normalize_edge_status(e.status or "active"), + attrs=dict(e.attrs or {}), + discovered_at=e.discovered_at, + last_seen_at=e.last_seen_at, + ) + + +def _normalize_endpoints( + a_id: str, b_id: str, a_port: str, b_port: str +) -> tuple[str, str, str, str]: + ap = normalize_ifname(a_port) + bp = normalize_ifname(b_port) + if a_id <= b_id: + return a_id, b_id, ap, bp + return b_id, a_id, bp, ap + + +def ensure_fabric_node_for_managed(db: Session, ne: ManagedNE) -> TopoFabricNode: + mid = str(ne.id or "").strip() + now = _utcnow() + + def _apply(row: TopoFabricNode) -> TopoFabricNode: + row.name = (ne.name or row.name or "")[:256] + row.ip = (ne.ip_address or row.ip or "")[:128] + row.vendor = (ne.vendor or row.vendor or "")[:64] + row.device_type = (ne.device_type or row.device_type or "")[:64] + row.last_seen_at = now + row.updated_at = now + return row + + row = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none() + if row is not None: + return _apply(row) + try: + with db.begin_nested(): + row = TopoFabricNode( + id=uuid4().hex, + managed_ne_id=mid, + ume_ne_id=None, + name=(ne.name or "")[:256], + ip=(ne.ip_address or "")[:128], + vendor=(ne.vendor or "")[:64], + device_type=(ne.device_type or "")[:64], + attrs={}, + last_seen_at=now, + created_at=now, + updated_at=now, + ) + db.add(row) + db.flush() + return row + except IntegrityError: + existing = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none() + if existing is None: + raise + return _apply(existing) + + +def ensure_fabric_node_for_ume( + db: Session, ume: UmeInventoryNE, *, device_type: str = "", vendor: str = "" +) -> TopoFabricNode: + uid = str(ume.ne_id or "").strip() + now = _utcnow() + name = (ume.host_name or ume.ne_name or ume.user_label or ume.ip_address or uid).strip() + + def _apply(row: TopoFabricNode) -> TopoFabricNode: + row.name = name[:256] + row.ip = (ume.ip_address or row.ip or "")[:128] + if vendor: + row.vendor = vendor[:64] + if device_type: + row.device_type = device_type[:64] + row.last_seen_at = now + row.updated_at = now + return row + + row = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none() + if row is not None: + return _apply(row) + try: + with db.begin_nested(): + row = TopoFabricNode( + id=uuid4().hex, + managed_ne_id=None, + ume_ne_id=uid, + name=name[:256], + ip=(ume.ip_address or "")[:128], + vendor=(vendor or ume.vendor or "ZTE")[:64], + device_type=(device_type or "zte_zxros")[:64], + attrs={}, + last_seen_at=now, + created_at=now, + updated_at=now, + ) + db.add(row) + db.flush() + return row + except IntegrityError: + existing = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none() + if existing is None: + raise + return _apply(existing) + + +def refresh_fabric_stats(db: Session) -> TopoFabricStats: + now = _utcnow() + row = db.get(TopoFabricStats, "global") if row is None: - raise HTTPException(status_code=404, detail="topology_map_not_found") + row = TopoFabricStats(id="global") + db.add(row) + row.node_count = int(db.query(func.count(TopoFabricNode.id)).scalar() or 0) + row.edge_count = int(db.query(func.count(TopoFabricEdge.id)).scalar() or 0) + row.edge_active = int( + db.query(func.count(TopoFabricEdge.id)) + .filter(TopoFabricEdge.status == "active") + .scalar() + or 0 + ) + row.edge_stale = int( + db.query(func.count(TopoFabricEdge.id)) + .filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT))) + .scalar() + or 0 + ) + row.updated_at = now + db.commit() + db.refresh(row) 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 get_fabric_summary(db: Session) -> FabricSummaryOut: + row = db.get(TopoFabricStats, "global") + if row is None: + row = refresh_fabric_stats(db) + return FabricSummaryOut( + node_count=row.node_count, + edge_count=row.edge_count, + edge_active=row.edge_active, + edge_stale=row.edge_stale, + last_discover_at=row.last_discover_at, + updated_at=row.updated_at, + ) -def create_map(db: Session, body: TopologyMapCreate) -> TopologyMapOut: +def list_fabric_nodes( + db: Session, + *, + keyword: str = "", + page: int = 1, + page_size: int = PAGE_DEFAULT, +) -> dict[str, Any]: + page = max(1, int(page or 1)) + page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT))) + q = db.query(TopoFabricNode) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + TopoFabricNode.name.ilike(like), + TopoFabricNode.ip.ilike(like), + TopoFabricNode.managed_ne_id.ilike(like), + TopoFabricNode.ume_ne_id.ilike(like), + ) + ) + total = int(q.count()) + rows = ( + q.order_by(TopoFabricNode.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return { + "total": total, + "page": page, + "page_size": page_size, + "items": [_node_out(n).model_dump() for n in rows], + } + + +def list_fabric_edges( + db: Session, + *, + node_id: str = "", + layer: str = "physical", + status: str = "", + source: str = "", + page: int = 1, + page_size: int = PAGE_DEFAULT, +) -> dict[str, Any]: + page = max(1, int(page or 1)) + page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT))) + q = db.query(TopoFabricEdge) + layer_v = str(layer or "physical").strip() or "physical" + q = q.filter(TopoFabricEdge.layer == layer_v) + nid = str(node_id or "").strip() + if nid: + q = q.filter(or_(TopoFabricEdge.a_node_id == nid, TopoFabricEdge.b_node_id == nid)) + st = str(status or "").strip().lower() + if st: + q = q.filter(TopoFabricEdge.status == st) + src = str(source or "").strip().lower() + if src: + q = q.filter(TopoFabricEdge.source == src) + total = int(q.count()) + rows = ( + q.order_by(TopoFabricEdge.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return { + "total": total, + "page": page, + "page_size": page_size, + "items": [_edge_out(e).model_dump() for e in rows], + } + + +def get_fabric_neighborhood( + db: Session, node_id: str, *, depth: int = 1, layer: str = "physical" +) -> FabricNeighborhoodOut: + center = str(node_id or "").strip() + if not center or db.get(TopoFabricNode, center) is None: + raise HTTPException(status_code=404, detail="fabric_node_not_found") + depth = max(1, min(3, int(depth or 1))) + layer_v = str(layer or "physical").strip() or "physical" + seen_nodes = {center} + frontier = {center} + edges: dict[str, TopoFabricEdge] = {} + for _ in range(depth): + if not frontier: + break + batch = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.layer == layer_v, + or_( + TopoFabricEdge.a_node_id.in_(list(frontier)), + TopoFabricEdge.b_node_id.in_(list(frontier)), + ), + ) + .limit(VIEW_GRAPH_EDGE_HARD_CAP) + .all() + ) + next_frontier: set[str] = set() + for e in batch: + edges[e.id] = e + for nid in (e.a_node_id, e.b_node_id): + if nid not in seen_nodes: + next_frontier.add(nid) + seen_nodes.add(nid) + frontier = next_frontier + nodes = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(list(seen_nodes))).all() + return FabricNeighborhoodOut( + center_node_id=center, + depth=depth, + nodes=[_node_out(n) for n in nodes], + edges=[_edge_out(e) for e in edges.values()], + ) + + +def upsert_fabric_edge( + db: Session, + *, + a_node_id: str, + b_node_id: str, + a_port: str, + b_port: str, + source: str = "lldp", + layer: str = "physical", + now: datetime | None = None, +) -> tuple[TopoFabricEdge, str]: + """Return (edge, action) where action is added|updated|kept_manual.""" + now = now or _utcnow() + a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port) + if a == b: + raise HTTPException(status_code=400, detail="edge_self_loop") + layer_v = str(layer or "physical").strip() or "physical" + src = str(source or "lldp").strip().lower() or "lldp" + if src == "stale": + src = "lldp" + if src not in {"lldp", "manual"}: + raise HTTPException(status_code=400, detail="invalid_edge_source") + row = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.layer == layer_v, + TopoFabricEdge.a_node_id == a, + TopoFabricEdge.b_node_id == b, + TopoFabricEdge.a_port == ap, + TopoFabricEdge.b_port == bp, + ) + .one_or_none() + ) + if row is None: + row = TopoFabricEdge( + id=uuid4().hex, + layer=layer_v, + a_node_id=a, + b_node_id=b, + a_port=ap, + b_port=bp, + source=src, + status="active", + attrs={}, + discovered_at=now if src == "lldp" else None, + last_seen_at=now, + created_at=now, + updated_at=now, + ) + db.add(row) + return row, "added" + if (row.source or "") == "manual" and src == "lldp": + return row, "kept_manual" + row.source = src + row.status = "active" + row.attrs = _clear_miss_attrs(_edge_attrs(row)) + if src == "lldp": + row.discovered_at = row.discovered_at or now + row.last_seen_at = now + row.updated_at = now + return row, "updated" + + +def _mark_replaced_port_peers( + db: Session, + *, + self_id: str, + local_port: str, + peer_id: str, + new_edge_id: str, + layer: str = "physical", + now: datetime | None = None, +) -> list[str]: + """Same local port now peers with a different NE → mark old edges missing (cutover). + + Returns ids of edges touched by this replacement (skip re-bump in same job). + """ + now = now or _utcnow() + lp = normalize_ifname(local_port) + if not self_id or not peer_id or not lp: + return [] + layer_v = str(layer or "physical").strip() or "physical" + candidates = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.layer == layer_v, + TopoFabricEdge.id != new_edge_id, + TopoFabricEdge.source != "manual", + or_(TopoFabricEdge.a_node_id == self_id, TopoFabricEdge.b_node_id == self_id), + ) + .all() + ) + handled: list[str] = [] + for e in candidates: + if e.a_node_id == self_id: + e_local, e_peer = e.a_port or "", e.b_node_id + else: + e_local, e_peer = e.b_port or "", e.a_node_id + if normalize_ifname(e_local) != lp: + continue + if e_peer == peer_id: + continue + _set_edge_missing(e, now, replaced_by_edge_id=new_edge_id) + handled.append(e.id) + return handled + + +def _apply_missing_and_purge( + db: Session, + *, + scanned_ok: set[str], + touched_edge_ids: set[str], + now: datetime | None = None, +) -> tuple[int, int]: + """Rule A: endpoint scanned OK but edge absent → missing; purge after N cycles. + + Returns (newly_marked_missing, purged). + """ + now = now or _utcnow() + if not scanned_ok: + return 0, 0 + edges = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.layer == "physical", + TopoFabricEdge.source != "manual", + or_( + TopoFabricEdge.a_node_id.in_(list(scanned_ok)), + TopoFabricEdge.b_node_id.in_(list(scanned_ok)), + ), + ) + .all() + ) + newly_marked = 0 + purged = 0 + for e in edges: + if e.id in touched_edge_ids: + continue + if e.a_node_id not in scanned_ok and e.b_node_id not in scanned_ok: + continue + if _set_edge_missing(e, now): + newly_marked += 1 + if _purge_edge_if_due(db, e): + purged += 1 + return newly_marked, purged + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + + +def _view_out(v: TopoView, *, node_count: int = 0) -> TopologyViewOut: + return TopologyViewOut( + id=v.id, + name=v.name, + remark=v.remark or "", + filter=dict(v.filter or {}), + viewport=dict(v.viewport or {}), + node_count=node_count, + created_at=v.created_at, + updated_at=v.updated_at, + ) + + +def _get_view_or_404(db: Session, view_id: str) -> TopoView: + vid = str(view_id or "").strip() + row = db.get(TopoView, vid) if vid else None + if row is None: + raise HTTPException(status_code=404, detail="topology_view_not_found") + return row + + +def list_views(db: Session) -> dict[str, Any]: + rows = db.query(TopoView).order_by(TopoView.updated_at.desc()).all() + items = [] + for v in rows: + nc = db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).count() + items.append(_view_out(v, node_count=nc).model_dump()) + return {"total": len(items), "items": items} + + +def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut: name = str(body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="name_required") now = _utcnow() - row = TopologyMap( + row = TopoView( id=uuid4().hex, name=name[:256], remark=str(body.remark or "")[:1024], + filter=dict(body.filter or {}), + viewport={}, created_at=now, updated_at=now, ) db.add(row) db.commit() db.refresh(row) - return _map_out(row) + return _view_out(row) -def update_map(db: Session, map_id: str, body: TopologyMapUpdate) -> TopologyMapOut: - row = _get_map_or_404(db, map_id) +def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> TopologyViewOut: + row = _get_view_or_404(db, view_id) if body.name is not None: name = str(body.name or "").strip() if not name: @@ -99,658 +649,1157 @@ def update_map(db: Session, map_id: str, body: TopologyMapUpdate) -> TopologyMap row.name = name[:256] if body.remark is not None: row.remark = str(body.remark or "")[:1024] + if body.filter is not None: + row.filter = dict(body.filter or {}) + if body.viewport is not None: + row.viewport = dict(body.viewport or {}) 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) + nc = db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).count() + return _view_out(row, node_count=nc) -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) +def delete_view(db: Session, view_id: str) -> dict[str, Any]: + row = _get_view_or_404(db, view_id) + db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == row.id).delete( + synchronize_session=False + ) + db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).delete(synchronize_session=False) db.delete(row) db.commit() - return {"ok": True, "map_id": map_id, "deleted": True} + return {"ok": True, "view_id": view_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 _connect_status_for_node(db: Session, n: TopoFabricNode) -> str: + if n.managed_ne_id: + ne = db.get(ManagedNE, n.managed_ne_id) + if ne is not None: + return ne.connect_status or "" + if n.ume_ne_id: + ume = ( + db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == n.ume_ne_id).one_or_none() + ) + if ume is not None: + return ume.connection_status or "" + return "" -def _ume_lookup(db: Session, ume_ids: set[str]) -> dict[str, UmeInventoryNE]: - ids = {str(x).strip() for x in ume_ids if str(x).strip()} - if not ids: - return {} - rows = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id.in_(list(ids))).all() - return {str(r.ne_id): r for r in rows} - - -def _node_out( - n: TopologyNode, - ne: ManagedNE | None, - ume: UmeInventoryNE | None = None, -) -> TopologyNodeOut: - label = (n.label or "").strip() - ne_name = "" - ne_ip = "" - vendor = "" - protocol = "" - connect_status = "" - if ne is not None: - if not label: - label = (ne.name or ne.ip_address or n.id)[:256] - ne_name = ne.name or "" - ne_ip = ne.ip_address or "" - vendor = ne.vendor or "" - protocol = ne.protocol or "" - connect_status = ne.connect_status or "" - elif ume is not None: - ume_name = (ume.host_name or ume.ne_name or ume.user_label or "").strip() - if not label: - label = (ume_name or ume.ip_address or n.id)[:256] - ne_name = ume_name - ne_ip = ume.ip_address or "" - vendor = (ume.vendor or "ZTE").strip() or "ZTE" - connect_status = ume.connection_status or "" - 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, - ne_ip=ne_ip, - vendor=vendor, - protocol=protocol, - connect_status=connect_status, +def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut: + view = _get_view_or_404(db, view_id) + vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() + truncated = False + reason = "" + if len(vnodes) > VIEW_GRAPH_NODE_HARD_CAP: + vnodes = vnodes[:VIEW_GRAPH_NODE_HARD_CAP] + truncated = True + reason = "too_many_view_nodes" + fids = [vn.fabric_node_id for vn in vnodes] + fabric_nodes = { + n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all() + } if fids else {} + filt = dict(view.filter or {}) + layer = str(filt.get("layer") or "physical").strip() or "physical" + status = str(filt.get("status") or "").strip().lower() + nodes_out: list[ViewNodeOut] = [] + for vn in vnodes: + fn = fabric_nodes.get(vn.fabric_node_id) + label = (vn.label or "").strip() + if not label and fn is not None: + label = (fn.name or fn.ip or vn.fabric_node_id)[:256] + nodes_out.append( + ViewNodeOut( + fabric_node_id=vn.fabric_node_id, + managed_ne_id=(fn.managed_ne_id if fn else "") or "", + ume_ne_id=(fn.ume_ne_id if fn else "") or "", + label=label, + x=float(vn.x or 0), + y=float(vn.y or 0), + locked=bool(vn.locked), + name=(fn.name if fn else "") or "", + ip=(fn.ip if fn else "") or "", + vendor=(fn.vendor if fn else "") or "", + device_type=(fn.device_type if fn else "") or "", + connect_status=_connect_status_for_node(db, fn) if fn else "", + ) + ) + edges_out: list[ViewEdgeOut] = [] + if fids: + q = db.query(TopoFabricEdge).filter( + TopoFabricEdge.layer == layer, + TopoFabricEdge.a_node_id.in_(fids), + TopoFabricEdge.b_node_id.in_(fids), + ) + if status: + st_norm = _normalize_edge_status(status) + if st_norm == _EDGE_STATUS_MISSING: + q = q.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT))) + else: + q = q.filter(TopoFabricEdge.status == st_norm) + edges = q.limit(VIEW_GRAPH_EDGE_HARD_CAP + 1).all() + if len(edges) > VIEW_GRAPH_EDGE_HARD_CAP: + edges = edges[:VIEW_GRAPH_EDGE_HARD_CAP] + truncated = True + reason = reason or "too_many_edges" + styles = { + s.fabric_edge_id: s + for s in db.query(TopoViewEdgeStyle) + .filter( + TopoViewEdgeStyle.view_id == view.id, + TopoViewEdgeStyle.fabric_edge_id.in_([e.id for e in edges]), + ) + .all() + } + for e in edges: + st = styles.get(e.id) + src = str(e.source or "lldp").strip().lower() or "lldp" + if src == "stale": + src = "lldp" + edges_out.append( + ViewEdgeOut( + id=e.id, + a_node_id=e.a_node_id, + b_node_id=e.b_node_id, + a_port=e.a_port or "", + b_port=e.b_port or "", + source=src, + status=_normalize_edge_status(e.status or "active"), + layer=e.layer or "physical", + stroke_color=(st.stroke_color if st else "") or "", + stroke_width=int(st.stroke_width if st else 0) or 0, + line_style=(st.line_style if st else "") or "", + discovered_at=e.discovered_at, + ) + ) + return TopologyViewGraphOut( + view=_view_out(view, node_count=len(nodes_out)), + nodes=nodes_out, + edges=edges_out, + truncated=truncated, + truncate_reason=reason, ) +def patch_view_positions( + db: Session, view_id: str, body: ViewPositionsPatch +) -> TopologyViewGraphOut: + view = _get_view_or_404(db, view_id) + now = _utcnow() + positions = list(body.positions or []) + if len(positions) > VIEW_GRAPH_NODE_HARD_CAP: + raise HTTPException(status_code=400, detail="too_many_positions") + existing = { + vn.fabric_node_id: vn + for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() + } + for p in positions: + fid = str(p.fabric_node_id or "").strip() + if not fid: + continue + if db.get(TopoFabricNode, fid) is None: + raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}") + row = existing.get(fid) + if row is None: + row = TopoViewNode( + id=uuid4().hex, + view_id=view.id, + fabric_node_id=fid, + x=float(p.x or 0), + y=float(p.y or 0), + label=str(p.label or "")[:256], + locked=bool(p.locked), + created_at=now, + updated_at=now, + ) + db.add(row) + existing[fid] = row + else: + if row.locked and not p.locked: + # allow unlock + move when explicitly unlocked in patch + pass + if row.locked and bool(p.locked): + continue + row.x = float(p.x or 0) + row.y = float(p.y or 0) + if p.label is not None: + row.label = str(p.label or "")[:256] + row.locked = bool(p.locked) + row.updated_at = now + view.updated_at = now + db.commit() + return get_view_graph(db, view.id) + + +def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> TopologyViewGraphOut: + view = _get_view_or_404(db, view_id) + now = _utcnow() + existing = { + vn.fabric_node_id + for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() + } + added_ids: list[str] = [] + for mid in body.managed_ne_ids or []: + mid_s = str(mid or "").strip() + if not mid_s: + continue + ne = db.get(ManagedNE, mid_s) + if ne is None: + continue + fn = ensure_fabric_node_for_managed(db, ne) + if fn.id not in existing: + added_ids.append(fn.id) + existing.add(fn.id) + default_profile = get_default_profile(db) + for uid in body.ume_ne_ids or []: + uid_s = str(uid or "").strip() + if not uid_s: + continue + ume = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == uid_s).one_or_none() + if ume is None: + continue + if default_profile is not None: + dtype, vendor = infer_device_type_vendor(str(ume.ne_type or ""), default_profile) + else: + dtype, vendor = "zte_zxros", (ume.vendor or "ZTE") + fn = ensure_fabric_node_for_ume(db, ume, device_type=dtype, vendor=vendor) + if fn.id not in existing: + added_ids.append(fn.id) + existing.add(fn.id) + for fid in body.fabric_node_ids or []: + fid_s = str(fid or "").strip() + if not fid_s or fid_s in existing: + continue + if db.get(TopoFabricNode, fid_s) is None: + continue + added_ids.append(fid_s) + existing.add(fid_s) + cols = max(1, int(len(added_ids) ** 0.5) or 1) + for i, fid in enumerate(added_ids): + x = (i % cols) * 180.0 + 40.0 + y = (i // cols) * 120.0 + 40.0 + db.add( + TopoViewNode( + id=uuid4().hex, + view_id=view.id, + fabric_node_id=fid, + x=x, + y=y, + label="", + locked=False, + created_at=now, + updated_at=now, + ) + ) + view.updated_at = now + db.commit() + return get_view_graph(db, view.id) + + +def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewGraphOut: + """Add fabric neighbors of current view nodes onto the view so edges can render.""" + # Collapse duplicate fabric nodes first (fixes R1/r1 + twin R2 after raced discovers). + merge_duplicate_fabric_nodes(db) + view = _get_view_or_404(db, view_id) + vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() + + # Drop view placements that still point at LLDP orphans (no inventory link). + orphan_vns = [] + for vn in vnodes: + fn = db.get(TopoFabricNode, vn.fabric_node_id) + if fn is None or not _is_inventory_node(fn): + orphan_vns.append(vn) + if orphan_vns: + for vn in orphan_vns: + db.delete(vn) + view.updated_at = _utcnow() + db.commit() + vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() + + existing = {vn.fabric_node_id for vn in vnodes} + if not existing: + return get_view_graph(db, view.id) + + filt = dict(view.filter or {}) + layer = str(filt.get("layer") or "physical").strip() or "physical" + peer_ids: set[str] = set() + for fid in existing: + rows = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.layer == layer, + or_(TopoFabricEdge.a_node_id == fid, TopoFabricEdge.b_node_id == fid), + ) + .all() + ) + for edge in rows: + peer = edge.b_node_id if edge.a_node_id == fid else edge.a_node_id + if not peer or peer in existing: + continue + fn = db.get(TopoFabricNode, peer) + # Project real inventory + LLDP placeholders; skip WebCRT twins / orphans. + if fn is None or not _is_inventory_node(fn): + continue + if _fabric_match_score(db, fn) < 2: + continue + peer_ids.add(peer) + + if not peer_ids: + return get_view_graph(db, view.id) + + now = _utcnow() + added = sorted(peer_ids) + cols = max(1, int(len(added) ** 0.5) or 1) + max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0) + base_x = max_x + 200.0 + for i, fid in enumerate(added): + if db.get(TopoFabricNode, fid) is None: + continue + x = base_x + (i % cols) * 180.0 + y = 40.0 + (i // cols) * 120.0 + db.add( + TopoViewNode( + id=uuid4().hex, + view_id=view.id, + fabric_node_id=fid, + x=x, + y=y, + label="", + locked=False, + created_at=now, + updated_at=now, + ) + ) + view.updated_at = now + db.commit() + return get_view_graph(db, view.id) + + +def remove_view_nodes(db: Session, view_id: str, fabric_node_ids: list[str]) -> TopologyViewGraphOut: + view = _get_view_or_404(db, view_id) + ids = [str(x).strip() for x in (fabric_node_ids or []) if str(x).strip()] + if ids: + db.query(TopoViewNode).filter( + TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id.in_(ids) + ).delete(synchronize_session=False) + view.updated_at = _utcnow() + db.commit() + return get_view_graph(db, view.id) + + _HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") _LINE_STYLES = {"", "solid", "dashed", "dotted"} -def _normalize_edge_style( - *, - stroke_color: str = "", - stroke_width: int = 0, - line_style: str = "", -) -> tuple[str, int, str]: - color = str(stroke_color or "").strip() +def patch_view_edge_style( + db: Session, view_id: str, body: ViewEdgeStylePatch +) -> TopologyViewGraphOut: + view = _get_view_or_404(db, view_id) + eid = str(body.fabric_edge_id or "").strip() + if not eid or db.get(TopoFabricEdge, eid) is None: + raise HTTPException(status_code=404, detail="fabric_edge_not_found") + color = str(body.stroke_color or "").strip() if color and not _HEX_COLOR_RE.match(color): raise HTTPException(status_code=400, detail="invalid_stroke_color") - try: - width = int(stroke_width or 0) - except (TypeError, ValueError) as exc: - raise HTTPException(status_code=400, detail="invalid_stroke_width") from exc + width = int(body.stroke_width or 0) if width < 0 or width > 12: raise HTTPException(status_code=400, detail="invalid_stroke_width") - style = str(line_style or "").strip().lower() + style = str(body.line_style or "").strip().lower() if style not in _LINE_STYLES: raise HTTPException(status_code=400, detail="invalid_line_style") - return color, width, style - - -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", - stroke_color=getattr(e, "stroke_color", None) or "", - stroke_width=int(getattr(e, "stroke_width", 0) or 0), - line_style=getattr(e, "line_style", None) or "", - 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}) - umes = _ume_lookup(db, {str(n.ume_ne_id or "") for n in nodes if n.ume_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 "")), - umes.get(str(n.ume_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) - - normalized_edges: list[tuple[TopologyEdgeIn, str, str, int, str]] = [] - 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") - if sid == tid: - raise HTTPException(status_code=400, detail="edge_self_loop") - src = str(e.source or "manual").strip().lower() or "manual" - if src not in {"manual", "lldp", "cdp", "stale"}: - raise HTTPException(status_code=400, detail="invalid_edge_source") - color, width, line = _normalize_edge_style( - stroke_color=getattr(e, "stroke_color", "") or "", - stroke_width=int(getattr(e, "stroke_width", 0) or 0), - line_style=getattr(e, "line_style", "") or "", - ) - normalized_edges.append((e, src, color, width, line)) - now = _utcnow() - prev_nodes = { - str(n.id): {"created_at": n.created_at} - for n in db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all() - } - prev_edges = { - str(e.id): {"created_at": e.created_at, "discovered_at": e.discovered_at} - for e in db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all() - } - 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.expire_all() - - for n in nodes_in: - nid = str(n.id).strip() - prev = prev_nodes.get(nid) or {} - created = getattr(n, "created_at", None) or prev.get("created_at") or now - db.add( - TopologyNode( - id=nid, - 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=created, - updated_at=now, - ) + row = ( + db.query(TopoViewEdgeStyle) + .filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid) + .one_or_none() + ) + if row is None: + row = TopoViewEdgeStyle( + id=uuid4().hex, + view_id=view.id, + fabric_edge_id=eid, + stroke_color=color, + stroke_width=width, + line_style=style, + created_at=now, + updated_at=now, ) - for e, src, color, width, line in normalized_edges: - eid = str(e.id).strip() or uuid4().hex - prev = prev_edges.get(eid) or {} - created = getattr(e, "created_at", None) or prev.get("created_at") or now - client_discovered = getattr(e, "discovered_at", None) - if src in {"lldp", "cdp", "stale"}: - discovered = client_discovered or prev.get("discovered_at") or now - else: - discovered = None - db.add( - TopologyEdge( - id=eid, - 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=src, - stroke_color=color, - stroke_width=width, - line_style=line, - discovered_at=discovered, - created_at=created, - updated_at=now, - ) - ) - row.updated_at = now + db.add(row) + else: + row.stroke_color = color + row.stroke_width = width + row.line_style = style + row.updated_at = now + view.updated_at = now db.commit() - return get_graph(db, row.id) + return get_view_graph(db, view.id) -def _norm_key(s: str) -> str: - return re_sub_host(str(s or "").strip().lower()) +# --------------------------------------------------------------------------- +# LLDP discovery → fabric +# --------------------------------------------------------------------------- -def re_sub_host(s: str) -> str: - # Strip domain / trailing punctuation for hostname matching. - t = s.split(".")[0].strip().lower() - return t.rstrip(".,;:") +def _is_inventory_node(n: TopoFabricNode) -> bool: + return bool(str(n.managed_ne_id or "").strip() or str(n.ume_ne_id or "").strip()) -def _peer_display( - peer: TopologyNode, - nes: dict[str, ManagedNE], - umes: dict[str, UmeInventoryNE], -) -> tuple[str, str, str]: - """Return (peer_ne_id, peer_name, peer_ip).""" - mid = str(peer.managed_ne_id or "").strip() - uid = str(peer.ume_ne_id or "").strip() - if mid and mid in nes: - ne = nes[mid] - return mid, (ne.name or peer.label or mid)[:256], str(ne.ip_address or "") - if uid and uid in umes: - ume = umes[uid] - name = (ume.host_name or ume.ne_name or ume.user_label or peer.label or uid).strip() - return uid, name[:256], str(ume.ip_address or "") - return "", (peer.label or peer.id)[:256], "" +def _managed_source(db: Session, ne_id: str | None) -> str: + mid = str(ne_id or "").strip() + if not mid: + return "" + ne = db.get(ManagedNE, mid) + if ne is None: + return "" + return str(ne.source or "").strip().lower() -def _match_neighbor_to_node( - hit: NeighborHit, - *, - nodes: list[TopologyNode], - nes: dict[str, ManagedNE], - umes: dict[str, UmeInventoryNE], - self_node_id: str, -) -> TopologyNode | None: - name_key = _norm_key(hit.remote_name) +def _ne_inventory_score(ne: ManagedNE) -> int: + """Prefer real inventory over LLDP placeholders; never prefer WebCRT twins.""" + src = str(ne.source or "").strip().lower() + if src == WEBCRT_NE_SOURCE: + return 0 + if src == LLDP_DISCOVERED_NE_SOURCE: + return 1 + return 2 + + +def _fabric_match_score(db: Session, n: TopoFabricNode) -> int: + """Higher = prefer when collapsing LLDP hits / duplicate IPs. + + WebCRT quick-connect intentionally allows duplicate IPs as separate ManagedNE + rows; those must lose to real inventory NEs with the same address. + LLDP placeholders (SSH shell, empty creds) rank above WebCRT, below real NEs. + """ + if str(n.ume_ne_id or "").strip(): + return 3 + mid = str(n.managed_ne_id or "").strip() + if not mid: + return 0 + src = _managed_source(db, mid) + if src == WEBCRT_NE_SOURCE: + return 1 + if src == LLDP_DISCOVERED_NE_SOURCE: + return 2 + return 4 + + +def _pick_managed_ne( + db: Session, *, ip: str = "", name_key: str = "" +) -> ManagedNE | None: + rows: list[ManagedNE] = [] + if ip: + rows = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).all() + elif name_key: + rows = ( + db.query(ManagedNE).filter(func.lower(ManagedNE.name) == name_key).all() + ) + if not rows: + return None + rows.sort(key=_ne_inventory_score, reverse=True) + best = rows[0] + # Only-WebCRT IP collision must not become a topology peer — treat as unmatched + # so discover can create an LLDP placeholder instead. + if _ne_inventory_score(best) == 0: + return None + return best + + +def ensure_lldp_discovered_managed_ne( + db: Session, *, remote_name: str = "", remote_ip: str = "" +) -> ManagedNE: + """SSH placeholder ManagedNE for an LLDP neighbor not in inventory. + + Intentionally empty IP / username / password — operator fills them later. + LLDP management IP (if any) is kept in ``source_ref`` / remark only. + """ + display = (str(remote_name or "").strip() or str(remote_ip or "").strip() or "unknown")[:256] + name_key = _norm_host(display) + ip_hint = str(remote_ip or "").strip()[:128] + now = _utcnow() + + # Reuse existing LLDP placeholder by normalized hostname. + if name_key: + for ne in ( + db.query(ManagedNE) + .filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE) + .all() + ): + if _norm_host(ne.name or "") == name_key: + if ip_hint and not str(ne.source_ref or "").strip(): + ne.source_ref = ip_hint + ne.updated_at = now + return ne + + row = ManagedNE( + id=uuid4().hex, + name=display, + vendor="Other", + device_type="generic", + ip_address="", + port=22, + protocol="ssh", + username="", + password_enc="", + enable_secret_enc="", + connect_status="unknown", + tags="", + remark=(f"LLDP discovered" + (f"; seen_mgmt_ip={ip_hint}" if ip_hint else ""))[:1024], + source=LLDP_DISCOVERED_NE_SOURCE, + source_ref=ip_hint, + created_at=now, + updated_at=now, + ) + db.add(row) + db.flush() + return row + + +def _match_hit_to_fabric_node( + db: Session, hit: NeighborHit, *, self_id: str +) -> TopoFabricNode | None: + name_key = _norm_host(hit.remote_name) ip_key = str(hit.remote_ip or "").strip() - for n in nodes: - if n.id == self_node_id: + candidates = db.query(TopoFabricNode).filter(TopoFabricNode.id != self_id).all() + + matched: list[TopoFabricNode] = [] + for n in candidates: + names = {_norm_host(n.name or "")} + ips = {str(n.ip or "").strip()} + if ip_key and ip_key in ips: + matched.append(n) continue - ne = nes.get(str(n.managed_ne_id or "")) - ume = umes.get(str(n.ume_ne_id or "")) - candidates = [ - _norm_key(n.label or ""), - _norm_key(ne.name if ne else ""), - str(ne.ip_address if ne else "").strip(), - _norm_key(ume.host_name if ume else ""), - _norm_key(ume.ne_name if ume else ""), - _norm_key(ume.user_label if ume else ""), - str(ume.ip_address if ume else "").strip(), - ] - cand_set = {_norm_key(c) for c in candidates if str(c or "").strip()} - if ip_key and ip_key in {str(c).strip() for c in candidates if str(c or "").strip()}: - return n - if name_key and name_key in cand_set: - return n + if name_key and name_key in names: + matched.append(n) + if matched: + matched.sort(key=lambda n: _fabric_match_score(db, n), reverse=True) + return matched[0] + + # Inventory not yet in fabric (or missed due to concurrent insert). + if ip_key: + ne = _pick_managed_ne(db, ip=ip_key) + if ne is not None: + return ensure_fabric_node_for_managed(db, ne) + ume = db.query(UmeInventoryNE).filter(UmeInventoryNE.ip_address == ip_key).first() + if ume is not None: + return ensure_fabric_node_for_ume(db, ume) + if name_key: + ne = _pick_managed_ne(db, name_key=name_key) + if ne is not None: + return ensure_fabric_node_for_managed(db, ne) return None -def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]: - lp = normalize_ifname(local_port) - rp = normalize_ifname(remote_port) - if a <= b: - return (a, b, lp, rp) - return (b, a, rp, lp) +def _retarget_fabric_edges(db: Session, *, from_id: str, to_id: str) -> None: + """Move edges from from_id onto to_id; drop duplicates / self-loops.""" + if not from_id or not to_id or from_id == to_id: + return + edges = ( + db.query(TopoFabricEdge) + .filter(or_(TopoFabricEdge.a_node_id == from_id, TopoFabricEdge.b_node_id == from_id)) + .all() + ) + for e in edges: + a = to_id if e.a_node_id == from_id else e.a_node_id + b = to_id if e.b_node_id == from_id else e.b_node_id + if a == b: + db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.fabric_edge_id == e.id).delete( + synchronize_session=False + ) + db.delete(e) + continue + na, nb, ap, bp = _normalize_endpoints(a, b, e.a_port or "", e.b_port or "") + clash = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.id != e.id, + TopoFabricEdge.layer == (e.layer or "physical"), + TopoFabricEdge.a_node_id == na, + TopoFabricEdge.b_node_id == nb, + TopoFabricEdge.a_port == ap, + TopoFabricEdge.b_port == bp, + ) + .one_or_none() + ) + if clash is not None: + # Keep the surviving edge fresher. + if (e.last_seen_at or e.updated_at) and ( + not clash.last_seen_at + or (e.last_seen_at and clash.last_seen_at and e.last_seen_at > clash.last_seen_at) + ): + clash.source = e.source or clash.source + clash.status = e.status or clash.status + clash.last_seen_at = e.last_seen_at or clash.last_seen_at + clash.discovered_at = e.discovered_at or clash.discovered_at + clash.updated_at = _utcnow() + db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.fabric_edge_id == e.id).delete( + synchronize_session=False + ) + db.delete(e) + continue + e.a_node_id = na + e.b_node_id = nb + e.a_port = ap + e.b_port = bp + e.updated_at = _utcnow() -_RAW_PREVIEW_MAX = 12_000 +def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]: + """Collapse duplicate fabric nodes (same managed/ume/name/ip) onto inventory canonicals.""" + nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.created_at.asc()).all() + merged = 0 + + # 1) Same managed_ne_id / ume_ne_id (constraint may be missing on old DBs). + by_managed: dict[str, list[TopoFabricNode]] = {} + by_ume: dict[str, list[TopoFabricNode]] = {} + for n in nodes: + mid = str(n.managed_ne_id or "").strip() + uid = str(n.ume_ne_id or "").strip() + if mid: + by_managed.setdefault(mid, []).append(n) + if uid: + by_ume.setdefault(uid, []).append(n) + + def _absorb(canon: TopoFabricNode, dupes: list[TopoFabricNode]) -> None: + nonlocal merged + for d in dupes: + if d.id == canon.id: + continue + _retarget_fabric_edges(db, from_id=d.id, to_id=canon.id) + # View placements: keep canon if present, else retarget; drop duplicate placements. + vnodes = db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == d.id).all() + for vn in vnodes: + exists = ( + db.query(TopoViewNode) + .filter( + TopoViewNode.view_id == vn.view_id, + TopoViewNode.fabric_node_id == canon.id, + ) + .one_or_none() + ) + if exists is not None: + db.delete(vn) + else: + vn.fabric_node_id = canon.id + vn.updated_at = _utcnow() + db.delete(d) + merged += 1 + + seen_absorb: set[str] = set() + for group in list(by_managed.values()) + list(by_ume.values()): + alive = [n for n in group if n.id not in seen_absorb and db.get(TopoFabricNode, n.id) is not None] + if len(alive) < 2: + continue + canon = next((n for n in alive if _is_inventory_node(n)), alive[0]) + _absorb(canon, alive) + for n in alive: + seen_absorb.add(n.id) + + # 2) Orphans (no inventory ids) that collide with inventory node by name/ip. + db.flush() + nodes = db.query(TopoFabricNode).all() + inventory = [n for n in nodes if _is_inventory_node(n)] + orphans = [n for n in nodes if not _is_inventory_node(n)] + inv_by_name: dict[str, TopoFabricNode] = {} + inv_by_ip: dict[str, TopoFabricNode] = {} + for n in sorted(inventory, key=lambda x: _fabric_match_score(db, x), reverse=True): + nk = _norm_host(n.name or "") + if nk and nk not in inv_by_name: + inv_by_name[nk] = n + ip = str(n.ip or "").strip() + if ip and ip not in inv_by_ip: + inv_by_ip[ip] = n + for o in orphans: + canon = None + ip = str(o.ip or "").strip() + nk = _norm_host(o.name or "") + if ip and ip in inv_by_ip: + canon = inv_by_ip[ip] + elif nk and nk in inv_by_name: + canon = inv_by_name[nk] + if canon is None: + continue + _absorb(canon, [o]) + + # 3) WebCRT session hosts sharing an IP with a real inventory fabric node. + db.flush() + nodes = db.query(TopoFabricNode).all() + by_ip: dict[str, list[TopoFabricNode]] = {} + for n in nodes: + ip = str(n.ip or "").strip() + if ip: + by_ip.setdefault(ip, []).append(n) + for group in by_ip.values(): + if len(group) < 2: + continue + real = [n for n in group if _fabric_match_score(db, n) >= 3] + webcrtish = [n for n in group if _fabric_match_score(db, n) == 1] + if not real or not webcrtish: + continue + canon = max(real, key=lambda n: _fabric_match_score(db, n)) + _absorb(canon, webcrtish) + + if merged: + db.commit() + refresh_fabric_stats(db) + return {"merged": merged} def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str: - """UI preview only; discovery parsing always uses the full command output.""" text = str(raw or "") if len(text) <= limit: return text return f"{text[:limit]}\n...[truncated preview {limit}/{len(text)} chars]" -def _discover_target_for_node( - n: TopologyNode, - *, - nes: dict[str, ManagedNE], - umes: dict[str, UmeInventoryNE], - filter_ids: set[str], - default_profile, -) -> dict[str, str] | None: - """Resolve CLI target for a topology node (managed preferred, else UME).""" - mid = str(n.managed_ne_id or "").strip() - uid = str(n.ume_ne_id or "").strip() - if filter_ids and mid not in filter_ids and uid not in filter_ids: - return None - if mid and mid in nes: - ne = nes[mid] - return { - "ne_id": ne.id, - "ume_ne_id": "", - "ne_name": ne.name or "", - "ne_ip": ne.ip_address or "", - "vendor": ne.vendor or "", - "device_type": ne.device_type or "", - } - if uid and uid in umes: - ume = umes[uid] +def _job_out(db: Session, job: TopoDiscoverJob, *, include_items: bool = True) -> FabricDiscoverJobOut: + items_out: list[FabricDiscoverJobItemOut] = [] + if include_items: + items = ( + db.query(TopoDiscoverJobItem) + .filter(TopoDiscoverJobItem.job_id == job.id) + .order_by(TopoDiscoverJobItem.created_at.asc()) + .all() + ) + for it in items: + unmatched = [ + FabricDiscoverUnmatched.model_validate(x) for x in (it.unmatched_json or [])[:40] + ] + items_out.append( + FabricDiscoverJobItemOut( + id=it.id, + job_id=it.job_id, + ne_id=it.ne_id or "", + ume_ne_id=it.ume_ne_id or "", + fabric_node_id=it.fabric_node_id or "", + ne_name=it.ne_name or "", + ne_ip=it.ne_ip or "", + ok=bool(it.ok), + command=it.command or "", + neighbors=int(it.neighbors or 0), + edges_added=int(it.edges_added or 0), + edges_updated=int(it.edges_updated or 0), + unmatched_count=int(it.unmatched_count or 0), + unmatched=unmatched, + parser_key=it.parser_key or "", + parser_stub=bool(it.parser_stub), + error=it.error or "", + raw_preview=it.raw_preview or "", + ) + ) + return FabricDiscoverJobOut( + id=job.id, + scope=job.scope, + trigger_mode=str(getattr(job, "trigger_mode", None) or "manual"), + status=job.status, + total=int(job.total or 0), + done=int(job.done or 0), + edges_added=int(job.edges_added or 0), + edges_updated=int(job.edges_updated or 0), + edges_stale=int(job.edges_stale or 0), + error=job.error or "", + started_at=job.started_at, + ended_at=job.ended_at, + items=items_out, + ) + + +def get_discover_job(db: Session, job_id: str) -> FabricDiscoverJobOut: + job = db.get(TopoDiscoverJob, str(job_id or "").strip()) + if job is None: + raise HTTPException(status_code=404, detail="discover_job_not_found") + return _job_out(db, job) + + +def _resolve_scan_targets( + db: Session, body: FabricDiscoverRequest +) -> list[dict[str, str]]: + scope = str(body.scope or "ne_ids").strip().lower() or "ne_ids" + default_profile = get_default_profile(db) + targets: list[dict[str, str]] = [] + if scope == "all_inventory": + for ne in db.query(ManagedNE).all(): + targets.append( + { + "ne_id": ne.id, + "ume_ne_id": "", + "ne_name": ne.name or "", + "ne_ip": ne.ip_address or "", + "vendor": ne.vendor or "", + "device_type": ne.device_type or "", + } + ) + return targets + filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()} + if not filter_ids: + raise HTTPException(status_code=400, detail="ne_ids_required") + for mid in list(filter_ids): + ne = db.get(ManagedNE, mid) + if ne is not None: + targets.append( + { + "ne_id": ne.id, + "ume_ne_id": "", + "ne_name": ne.name or "", + "ne_ip": ne.ip_address or "", + "vendor": ne.vendor or "", + "device_type": ne.device_type or "", + } + ) + filter_ids.discard(mid) + for uid in list(filter_ids): + ume = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == uid).one_or_none() + if ume is None: + continue if default_profile is not None: dtype, vendor = infer_device_type_vendor(str(ume.ne_type or ""), default_profile) else: dtype, vendor = "zte_zxros", (ume.vendor or "ZTE") name = (ume.host_name or ume.ne_name or ume.user_label or ume.ip_address or uid).strip() - return { - "ne_id": uid, - "ume_ne_id": uid, - "ne_name": name, - "ne_ip": ume.ip_address or "", - "vendor": vendor or (ume.vendor or "ZTE"), - "device_type": dtype or "zte_zxros", - } - return None - - -def iter_discover_neighbors( - db: Session, - map_id: str, - body: TopologyDiscoverRequest, -): - """Yield discovery progress events: start / ne_start / ne_result / done / error.""" - 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}) - umes = _ume_lookup(db, {str(n.ume_ne_id or "") for n in nodes if n.ume_ne_id}) - default_profile = get_default_profile(db) - - filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()} - scan_targets: list[tuple[TopologyNode, dict[str, str]]] = [] - for n in nodes: - target = _discover_target_for_node( - n, nes=nes, umes=umes, filter_ids=filter_ids, default_profile=default_profile - ) - if target is not None: - scan_targets.append((n, target)) - - 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 - stale_count = 0 - now = _utcnow() - proto_req = str(body.protocol or "auto").strip().lower() or "auto" - total = len(scan_targets) - touched_edge_ids: set[str] = set() - scanned_ok_node_ids: set[str] = set() - - yield { - "type": "start", - "map_id": row.id, - "protocol": proto_req, - "total": total, - } - - for index, (n, target) in enumerate(scan_targets, start=1): - yield { - "type": "ne_start", - "index": index, - "total": total, - "ne_id": target["ne_id"], - "ne_name": target["ne_name"], - "ne_ip": target["ne_ip"], - } - cmd, proto_tag = pick_neighbor_command( - protocol=proto_req, - vendor=target["vendor"], - device_type=target["device_type"], - ) - if not cmd: - result = TopologyDiscoverNeResult( - ne_id=target["ne_id"], - ne_name=target["ne_name"], - ne_ip=target["ne_ip"], - ok=False, - error="no_command_for_vendor", - ) - results.append(result) - yield { - "type": "ne_result", - "index": index, - "total": total, - "result": result.model_dump(mode="json"), - "edges_added": added, - "edges_updated": updated, - "edges_stale": stale_count, + targets.append( + { + "ne_id": uid, + "ume_ne_id": uid, + "ne_name": name, + "ne_ip": ume.ip_address or "", + "vendor": vendor or (ume.vendor or "ZTE"), + "device_type": dtype or "zte_zxros", } - continue + ) + return targets + +def _discover_one_target( + target: dict[str, str], + *, + auto_add_unmatched: bool, +) -> dict[str, Any]: + """Run LLDP for one NE in a fresh DB session.""" + db = SessionLocal() + try: + now = _utcnow() + if target["ume_ne_id"] and not target["ne_id"]: + pass + fabric_node: TopoFabricNode | None = None + managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None + if managed is not None: + fabric_node = ensure_fabric_node_for_managed(db, managed) + elif target.get("ume_ne_id"): + ume = ( + db.query(UmeInventoryNE) + .filter(UmeInventoryNE.ne_id == target["ume_ne_id"]) + .one_or_none() + ) + if ume is not None: + fabric_node = ensure_fabric_node_for_ume( + db, + ume, + device_type=target.get("device_type") or "", + vendor=target.get("vendor") or "", + ) + if fabric_node is None: + return { + "ne_id": target["ne_id"], + "ume_ne_id": target.get("ume_ne_id") or "", + "fabric_node_id": "", + "ne_name": target.get("ne_name") or "", + "ne_ip": target.get("ne_ip") or "", + "ok": False, + "error": "fabric_node_resolve_failed", + } + + cmd, _proto = pick_neighbor_command( + vendor=target.get("vendor") or "", + device_type=target.get("device_type") or "", + ) exec_kwargs: dict[str, Any] = {"read_timeout_sec": 60} - if target["ume_ne_id"] and not str(n.managed_ne_id or "").strip(): + if target.get("ume_ne_id") and not db.get(ManagedNE, target["ne_id"]): exec_kwargs["ume_ne_id"] = target["ume_ne_id"] else: exec_kwargs["ne_id"] = target["ne_id"] try: exec_out = execute_managed_ne_commands(db, [cmd], **exec_kwargs) except HTTPException as exc: - result = TopologyDiscoverNeResult( - ne_id=target["ne_id"], - ne_name=target["ne_name"], - ne_ip=target["ne_ip"], - ok=False, - command=cmd, - error=str(exc.detail or "exec_failed")[:500], - ) - results.append(result) - yield { - "type": "ne_result", - "index": index, - "total": total, - "result": result.model_dump(mode="json"), - "edges_added": added, - "edges_updated": updated, - "edges_stale": stale_count, + db.commit() + return { + "ne_id": target["ne_id"], + "ume_ne_id": target.get("ume_ne_id") or "", + "fabric_node_id": fabric_node.id, + "ne_name": target.get("ne_name") or "", + "ne_ip": target.get("ne_ip") or "", + "ok": False, + "command": cmd, + "error": str(exc.detail or "exec_failed")[:500], } - continue if not exec_out.get("ok"): - result = TopologyDiscoverNeResult( - ne_id=target["ne_id"], - ne_name=target["ne_name"], - ne_ip=target["ne_ip"], - ok=False, - command=cmd, - error=str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500], - ) - results.append(result) - yield { - "type": "ne_result", - "index": index, - "total": total, - "result": result.model_dump(mode="json"), - "edges_added": added, - "edges_updated": updated, - "edges_stale": stale_count, + db.commit() + return { + "ne_id": target["ne_id"], + "ume_ne_id": target.get("ume_ne_id") or "", + "fabric_node_id": fabric_node.id, + "ne_name": target.get("ne_name") or "", + "ne_ip": target.get("ne_ip") or "", + "ok": False, + "command": cmd, + "error": str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500], } - continue - scanned_ok_node_ids.add(n.id) raw = str(exec_out.get("output") or "") - pkey, is_stub = parser_meta(vendor=target["vendor"], device_type=target["device_type"]) + pkey, is_stub = parser_meta( + vendor=target.get("vendor") or "", device_type=target.get("device_type") or "" + ) hits = parse_neighbor_output( raw, - protocol=proto_tag, - vendor=target["vendor"], - device_type=target["device_type"], + protocol="lldp", + vendor=target.get("vendor") or "", + device_type=target.get("device_type") or "", ) - ne_added = 0 - ne_updated = 0 - unmatched: list[TopologyDiscoverUnmatched] = [] - links: list[TopologyDiscoverLink] = [] + added = 0 + updated = 0 + unmatched: list[dict[str, str]] = [] + touched: list[str] = [] + replaced: list[str] = [] for hit in hits: - peer = _match_neighbor_to_node( - hit, nodes=nodes, nes=nes, umes=umes, self_node_id=n.id - ) + peer = _match_hit_to_fabric_node(db, hit, self_id=fabric_node.id) if peer is None: - unmatched.append( - TopologyDiscoverUnmatched( - remote_name=(hit.remote_name or "").strip()[:256], - remote_ip=(hit.remote_ip or "").strip()[:128], - local_port=(hit.local_port or "").strip()[:128], - remote_port=(hit.remote_port or "").strip()[:128], + if auto_add_unmatched and (hit.remote_name or hit.remote_ip): + # Not in inventory → SSH placeholder ManagedNE (empty IP/creds). + placeholder = ensure_lldp_discovered_managed_ne( + db, + remote_name=(hit.remote_name or "").strip(), + remote_ip=(hit.remote_ip or "").strip(), ) - ) - 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 - peer_ne_id, peer_name, peer_ip = _peer_display(peer, nes, umes) - cur = existing.get(key) - if cur is not None: - if (cur.source or "manual") == "manual": - links.append( - TopologyDiscoverLink( - peer_node_id=peer.id, - peer_ne_id=peer_ne_id, - peer_name=peer_name, - peer_ip=peer_ip, - local_port=local_port, - remote_port=remote_port, - protocol=edge_proto, - action="kept_manual", - ) + peer = ensure_fabric_node_for_managed(db, placeholder) + peer.attrs = dict(peer.attrs or {}) + peer.attrs["from_lldp_unmatched"] = True + peer.last_seen_at = now + peer.updated_at = now + else: + unmatched.append( + { + "remote_name": (hit.remote_name or "").strip()[:256], + "remote_ip": (hit.remote_ip or "").strip()[:128], + "local_port": (hit.local_port or "").strip()[:128], + "remote_port": (hit.remote_port or "").strip()[:128], + } ) 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 - touched_edge_ids.add(cur.id) - ne_updated += 1 + edge, action = upsert_fabric_edge( + db, + a_node_id=fabric_node.id, + b_node_id=peer.id, + a_port=(hit.local_port or ""), + b_port=(hit.remote_port or ""), + source="lldp", + now=now, + ) + touched.append(edge.id) + # Same local port, different peer → immediate missing (cutover). + replaced.extend( + _mark_replaced_port_peers( + db, + self_id=fabric_node.id, + local_port=(hit.local_port or ""), + peer_id=peer.id, + new_edge_id=edge.id, + now=now, + ) + ) + if action == "added": + added += 1 + elif action == "updated": updated += 1 - links.append( - TopologyDiscoverLink( - peer_node_id=peer.id, - peer_ne_id=peer_ne_id, - peer_name=peer_name, - peer_ip=peer_ip, - local_port=local_port, - remote_port=remote_port, - protocol=edge_proto, - action="updated", - ) - ) - continue - 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 - touched_edge_ids.add(new_edge.id) - ne_added += 1 - added += 1 - links.append( - TopologyDiscoverLink( - peer_node_id=peer.id, - peer_ne_id=peer_ne_id, - peer_name=peer_name, - peer_ip=peer_ip, - local_port=local_port, - remote_port=remote_port, - protocol=edge_proto, - action="added", - ) - ) - + fabric_node.last_seen_at = now + fabric_node.updated_at = now + db.commit() stub_flag = bool(is_stub and raw.strip() and not hits) - result = TopologyDiscoverNeResult( - ne_id=target["ne_id"], - ne_name=target["ne_name"], - ne_ip=target["ne_ip"], - ok=True, - command=cmd, - neighbors=len(hits), - edges_added=ne_added, - edges_updated=ne_updated, - unmatched_count=len(unmatched), - unmatched=unmatched[:40], - links=links[:80], - parser_key=pkey, - parser_stub=stub_flag, - raw_preview=_raw_preview(raw), - error="parser_stub" if stub_flag else "", - ) - results.append(result) - yield { - "type": "ne_result", - "index": index, - "total": total, - "result": result.model_dump(mode="json"), + return { + "ne_id": target["ne_id"], + "ume_ne_id": target.get("ume_ne_id") or "", + "fabric_node_id": fabric_node.id, + "ne_name": target.get("ne_name") or "", + "ne_ip": target.get("ne_ip") or "", + "ok": True, + "command": cmd, + "neighbors": len(hits), "edges_added": added, "edges_updated": updated, - "edges_stale": stale_count, + "unmatched_count": len(unmatched), + "unmatched": unmatched[:40], + "parser_key": pkey, + "parser_stub": stub_flag, + "error": "parser_stub" if stub_flag else "", + "raw_preview": _raw_preview(raw), + "touched_edge_ids": touched, + "replaced_edge_ids": replaced, + "scanned_node_id": fabric_node.id, } - - # Mark previously discovered edges not refreshed by this run as stale - # (only when at least one endpoint was successfully scanned). - if scanned_ok_node_ids: - for e in edges: - src = (e.source or "manual").strip().lower() - if src not in {"lldp", "cdp", "stale"}: - continue - if e.id in touched_edge_ids: - continue - if e.source_node_id not in scanned_ok_node_ids and e.target_node_id not in scanned_ok_node_ids: - continue - e.source = "stale" - e.updated_at = now - stale_count += 1 - - row.updated_at = now - db.commit() - graph = get_graph(db, row.id) - report = TopologyDiscoverOut( - map_id=row.id, - protocol=proto_req, - scanned=len(results), - edges_added=added, - edges_updated=updated, - edges_stale=stale_count, - results=results, - graph=graph, - ) - yield {"type": "done", "report": report.model_dump(mode="json")} + except Exception as exc: # noqa: BLE001 + db.rollback() + return { + "ne_id": target.get("ne_id") or "", + "ume_ne_id": target.get("ume_ne_id") or "", + "fabric_node_id": "", + "ne_name": target.get("ne_name") or "", + "ne_ip": target.get("ne_ip") or "", + "ok": False, + "error": str(exc)[:500], + } + finally: + db.close() -def discover_neighbors( +def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None: + db = SessionLocal() + try: + job = db.get(TopoDiscoverJob, job_id) + if job is None: + return + job.status = "running" + job.started_at = _utcnow() + job.updated_at = job.started_at + try: + targets = _resolve_scan_targets(db, body) + except HTTPException as exc: + job.status = "failed" + job.error = str(exc.detail or "resolve_failed")[:1024] + job.ended_at = _utcnow() + job.updated_at = job.ended_at + db.commit() + return + job.total = len(targets) + db.commit() + + concurrency = max(1, min(32, int(body.concurrency or 4))) + added = 0 + updated = 0 + stale = 0 + scanned_ok: set[str] = set() + touched_edges: set[str] = set() + + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futs = { + pool.submit( + _discover_one_target, t, auto_add_unmatched=bool(body.auto_add_unmatched) + ): t + for t in targets + } + for fut in as_completed(futs): + result = fut.result() + item = TopoDiscoverJobItem( + id=uuid4().hex, + job_id=job_id, + ne_id=str(result.get("ne_id") or ""), + ume_ne_id=str(result.get("ume_ne_id") or ""), + fabric_node_id=str(result.get("fabric_node_id") or ""), + ne_name=str(result.get("ne_name") or "")[:256], + ne_ip=str(result.get("ne_ip") or "")[:128], + ok=bool(result.get("ok")), + command=str(result.get("command") or "")[:256], + neighbors=int(result.get("neighbors") or 0), + edges_added=int(result.get("edges_added") or 0), + edges_updated=int(result.get("edges_updated") or 0), + unmatched_count=int(result.get("unmatched_count") or 0), + unmatched_json=list(result.get("unmatched") or []), + parser_key=str(result.get("parser_key") or "")[:64], + parser_stub=bool(result.get("parser_stub")), + error=str(result.get("error") or "")[:1024], + raw_preview=str(result.get("raw_preview") or ""), + created_at=_utcnow(), + ) + db.add(item) + added += int(result.get("edges_added") or 0) + updated += int(result.get("edges_updated") or 0) + if result.get("ok") and result.get("scanned_node_id"): + scanned_ok.add(str(result["scanned_node_id"])) + for eid in result.get("touched_edge_ids") or []: + touched_edges.add(str(eid)) + # Cutover edges already marked missing — skip same-job miss bump. + for eid in result.get("replaced_edge_ids") or []: + touched_edges.add(str(eid)) + job.done = int(job.done or 0) + 1 + job.edges_added = added + job.edges_updated = updated + job.updated_at = _utcnow() + db.commit() + + # Absent on a successfully scanned endpoint → missing; purge after N cycles. + if scanned_ok: + newly_missing, purged = _apply_missing_and_purge( + db, + scanned_ok=scanned_ok, + touched_edge_ids=touched_edges, + ) + stale = newly_missing + purged + job.edges_stale = stale + db.commit() + + stats = db.get(TopoFabricStats, "global") + if stats is None: + stats = TopoFabricStats(id="global") + db.add(stats) + stats.last_discover_at = _utcnow() + db.commit() + merge_duplicate_fabric_nodes(db) + refresh_fabric_stats(db) + + job = db.get(TopoDiscoverJob, job_id) + if job is not None: + job.status = "done" + job.ended_at = _utcnow() + job.updated_at = job.ended_at + job.edges_added = added + job.edges_updated = updated + job.edges_stale = stale + db.commit() + except Exception as exc: # noqa: BLE001 + db.rollback() + job = db.get(TopoDiscoverJob, job_id) + if job is not None: + job.status = "failed" + job.error = str(exc)[:1024] + job.ended_at = _utcnow() + job.updated_at = job.ended_at + db.commit() + finally: + db.close() + with _JOB_LOCK: + _RUNNING_JOBS.discard(job_id) + + +def start_discover_job( db: Session, - map_id: str, - body: TopologyDiscoverRequest, -) -> TopologyDiscoverOut: - report: TopologyDiscoverOut | None = None - for event in iter_discover_neighbors(db, map_id, body): - if event.get("type") == "done": - report = TopologyDiscoverOut.model_validate(event.get("report") or {}) - if report is None: - raise HTTPException(status_code=500, detail="discover_failed") - return report + body: FabricDiscoverRequest, + *, + trigger_mode: str = "manual", +) -> FabricDiscoverJobOut: + scope = str(body.scope or "ne_ids").strip().lower() or "ne_ids" + if scope not in {"all_inventory", "ne_ids"}: + raise HTTPException(status_code=400, detail="invalid_scope") + trig = str(trigger_mode or getattr(body, "trigger_mode", None) or "manual").strip().lower() or "manual" + if trig not in {"manual", "schedule", "topology"}: + trig = "manual" + now = _utcnow() + job = TopoDiscoverJob( + id=uuid4().hex, + scope=scope, + trigger_mode=trig, + ne_ids_json=list(body.ne_ids or []), + status="pending", + total=0, + done=0, + created_at=now, + updated_at=now, + ) + db.add(job) + db.commit() + db.refresh(job) + with _JOB_LOCK: + _RUNNING_JOBS.add(job.id) + thread = threading.Thread( + target=_run_discover_job, + args=(job.id, body), + name=f"topo-discover-{job.id[:8]}", + daemon=True, + ) + thread.start() + return _job_out(db, job, include_items=False) diff --git a/packages/netx-mcp/README.md b/packages/netx-mcp/README.md index 455039a..fef3473 100644 --- a/packages/netx-mcp/README.md +++ b/packages/netx-mcp/README.md @@ -36,11 +36,13 @@ python -m netx_mcp [`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。 -## 工具(12) +## 工具(21) UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` -托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe` +托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` + +拓扑 Fabric:`getTopologySummary`, `queryTopologyNodes`, `queryTopologyEdges`, `getTopologyNeighborhood`, `runLldpDiscover`, `getLldpDiscoverJob`, `listTopologyViews`, `getTopologyView` ## 兼容 diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/packages/netx-mcp/src/netx_mcp/http_tools.py index 3c32332..cd5d096 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/packages/netx-mcp/src/netx_mcp/http_tools.py @@ -1,4 +1,4 @@ -"""MCP tool schemas and HTTP-backed handlers (12 tools: UME + managed NE; no Excel import batch).""" +"""MCP tool schemas and HTTP-backed handlers (UME + managed NE + topology fabric).""" from __future__ import annotations @@ -285,6 +285,81 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]: return http_json("GET", "/v1/cli/targets", params=params) +def _get_topology_summary(args: dict[str, Any]) -> dict[str, Any]: + _ = args + return http_json("GET", "/v1/topology/fabric/summary", params=None) + + +def _query_topology_nodes(args: dict[str, Any]) -> dict[str, Any]: + page = max(1, int(args.get("page") or 1)) + page_size = min(500, max(1, int(args.get("page_size") or 50))) + params: dict[str, Any] = {"page": page, "page_size": page_size} + if str(args.get("keyword") or "").strip(): + params["keyword"] = str(args.get("keyword")).strip() + return http_json("GET", "/v1/topology/fabric/nodes", params=params) + + +def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]: + page = max(1, int(args.get("page") or 1)) + page_size = min(500, max(1, int(args.get("page_size") or 50))) + params: dict[str, Any] = { + "page": page, + "page_size": page_size, + "layer": str(args.get("layer") or "physical").strip() or "physical", + } + if str(args.get("node_id") or "").strip(): + params["node_id"] = str(args.get("node_id")).strip() + if str(args.get("status") or "").strip(): + params["status"] = str(args.get("status")).strip() + if str(args.get("source") or "").strip(): + params["source"] = str(args.get("source")).strip() + return http_json("GET", "/v1/topology/fabric/edges", params=params) + + +def _get_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]: + node_id = str(args.get("node_id") or "").strip() + if not node_id: + return {"ok": False, "error": "node_id_required", "error_code": "node_id_required"} + params: dict[str, Any] = { + "node_id": node_id, + "depth": max(1, min(3, int(args.get("depth") or 1))), + "layer": str(args.get("layer") or "physical").strip() or "physical", + } + return http_json("GET", "/v1/topology/fabric/neighborhood", params=params) + + +def _run_lldp_discover(args: dict[str, Any]) -> dict[str, Any]: + scope = str(args.get("scope") or "ne_ids").strip() or "ne_ids" + body: dict[str, Any] = { + "scope": scope, + "auto_add_unmatched": bool(args.get("auto_add_unmatched") or False), + "concurrency": max(1, min(32, int(args.get("concurrency") or 4))), + } + ne_ids = args.get("ne_ids") + if isinstance(ne_ids, list): + body["ne_ids"] = [str(x).strip() for x in ne_ids if str(x).strip()] + return http_post_json("/v1/topology/fabric/discover", body, timeout=60.0) + + +def _get_lldp_discover_job(args: dict[str, Any]) -> dict[str, Any]: + job_id = str(args.get("job_id") or "").strip() + if not job_id: + return {"ok": False, "error": "job_id_required", "error_code": "job_id_required"} + return http_json("GET", f"/v1/topology/fabric/discover/{job_id}", params=None) + + +def _list_topology_views(args: dict[str, Any]) -> dict[str, Any]: + _ = args + return http_json("GET", "/v1/topology/views", params=None) + + +def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]: + view_id = str(args.get("view_id") or "").strip() + if not view_id: + return {"ok": False, "error": "view_id_required", "error_code": "view_id_required"} + return http_json("GET", f"/v1/topology/views/{view_id}", params=None) + + HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "queryUmeAlarms", @@ -469,6 +544,96 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, + { + "name": "getTopologySummary", + "description": "Fabric topology summary: node/edge counts (active/stale) and last LLDP discover time.", + "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "name": "queryTopologyNodes", + "description": "Page fabric topology nodes (keyword optional). Never dumps full fabric.", + "inputSchema": { + "type": "object", + "properties": { + "keyword": {"type": "string"}, + "page": {"type": "integer", "minimum": 1, "default": 1}, + "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "name": "queryTopologyEdges", + "description": "Page fabric edges (physical layer by default); filter by node_id/status/source.", + "inputSchema": { + "type": "object", + "properties": { + "node_id": {"type": "string"}, + "layer": {"type": "string", "default": "physical"}, + "status": {"type": "string", "enum": ["active", "stale"]}, + "source": {"type": "string", "enum": ["lldp", "manual", "stale"]}, + "page": {"type": "integer", "minimum": 1, "default": 1}, + "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "name": "getTopologyNeighborhood", + "description": "Get fabric neighborhood around a node (depth 1-3).", + "inputSchema": { + "type": "object", + "properties": { + "node_id": {"type": "string"}, + "depth": {"type": "integer", "minimum": 1, "maximum": 3, "default": 1}, + "layer": {"type": "string", "default": "physical"}, + }, + "required": ["node_id"], + "additionalProperties": False, + }, + }, + { + "name": "runLldpDiscover", + "description": "Start async LLDP discovery into fabric (scope=ne_ids|all_inventory). Poll with getLldpDiscoverJob.", + "inputSchema": { + "type": "object", + "properties": { + "scope": {"type": "string", "enum": ["ne_ids", "all_inventory"], "default": "ne_ids"}, + "ne_ids": {"type": "array", "items": {"type": "string"}}, + "auto_add_unmatched": {"type": "boolean", "default": False}, + "concurrency": {"type": "integer", "minimum": 1, "maximum": 32, "default": 4}, + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "name": "getLldpDiscoverJob", + "description": "Get LLDP discover job status, counters, and per-NE items/unmatched.", + "inputSchema": { + "type": "object", + "properties": {"job_id": {"type": "string"}}, + "required": ["job_id"], + "additionalProperties": False, + }, + }, + { + "name": "listTopologyViews", + "description": "List topology views (presentation canvases over fabric).", + "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "name": "getTopologyView", + "description": "Get a topology view graph (nodes with positions + fabric edges between them).", + "inputSchema": { + "type": "object", + "properties": {"view_id": {"type": "string"}}, + "required": ["view_id"], + "additionalProperties": False, + }, + }, ] _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { @@ -485,6 +650,14 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "getManagedNe": _get_managed_ne, "execManagedNe": _exec_managed_ne, "listCliTargets": _list_cli_targets, + "getTopologySummary": _get_topology_summary, + "queryTopologyNodes": _query_topology_nodes, + "queryTopologyEdges": _query_topology_edges, + "getTopologyNeighborhood": _get_topology_neighborhood, + "runLldpDiscover": _run_lldp_discover, + "getLldpDiscoverJob": _get_lldp_discover_job, + "listTopologyViews": _list_topology_views, + "getTopologyView": _get_topology_view, } diff --git a/tests/test_lldp_collect.py b/tests/test_lldp_collect.py new file mode 100644 index 0000000..1c44fff --- /dev/null +++ b/tests/test_lldp_collect.py @@ -0,0 +1,65 @@ +"""LLDP collect policy / dashboard (network topology management).""" + +from __future__ import annotations + +import unittest +from uuid import uuid4 + +from netx_api.db import Base, SessionLocal, engine +from netx_api.lldp_collect_schemas import LldpCollectPolicyUpdate +from netx_api.lldp_collect_service import ( + ensure_policy, + get_dashboard, + next_due_at, + update_policy, +) +from netx_api.models import LldpCollectPolicy + + +class LldpCollectTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + Base.metadata.create_all(bind=engine) + + def setUp(self) -> None: + self.db = SessionLocal() + self.db.query(LldpCollectPolicy).delete() + self.db.commit() + + def tearDown(self) -> None: + # Do not leave scheduled collect enabled in shared DB. + row = self.db.get(LldpCollectPolicy, 1) + if row is not None: + row.enabled = False + self.db.commit() + self.db.close() + + def test_policy_defaults_disabled(self) -> None: + row = ensure_policy(self.db) + self.assertEqual(row.id, 1) + self.assertFalse(row.enabled) + dash = get_dashboard(self.db) + self.assertFalse(dash.policy.enabled) + self.assertIsNone(dash.next_due_at) + + def test_policy_enable_updates(self) -> None: + ensure_policy(self.db) + out = update_policy( + self.db, + LldpCollectPolicyUpdate( + enabled=True, + interval_days=2, + concurrency=6, + scope_mode="all", + auto_add_unmatched=True, + ), + ) + self.assertTrue(out.enabled) + self.assertEqual(out.interval_days, 2) + self.assertEqual(out.concurrency, 6) + due = next_due_at(self.db, ensure_policy(self.db)) + self.assertIsNotNone(due) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_topology.py b/tests/test_topology.py index 17a62f8..d58f9f1 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1,23 +1,32 @@ -"""Unit tests for topology CRUD and LLDP/CDP parsers.""" +"""Unit tests for fabric topology + LLDP parsers (no CDP discovery).""" from __future__ import annotations +import time 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.device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE +from netx_api.models import ( + ManagedNE, + TopoDiscoverJob, + TopoDiscoverJobItem, + TopoFabricEdge, + TopoFabricNode, + TopoView, + TopoViewEdgeStyle, + TopoViewNode, +) from netx_api.topology_schemas import ( - TopologyDiscoverRequest, - TopologyEdgeIn, - TopologyGraphPut, - TopologyMapCreate, - TopologyNodeIn, + FabricDiscoverRequest, + TopologyViewCreate, + ViewNodesAdd, + ViewPositionsPatch, + ViewNodeIn, ) @@ -31,48 +40,13 @@ 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 + IP: 192.168.0.1 """ HUAWEI_LLDP = """ @@ -86,582 +60,643 @@ 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) -""" - ZTE_LLDP_BRIEF = """ -KND-PUN-EN1-Z20HS#show lldp neighbor brief -23:10:28 Indonesia Wed Jul 29 2026 -Scope codes: - NB = Nearest Bridge - NC = Nearest Customer Bridge - NTPMR = Nearest non-TPMR Bridge - -Total neighbors: 11 -Local Interface Scope Chassis ID Port ID Holdtime System Name ----------------------------------------------------------------------------------------------------------------------------------- -cgei-1/1/0/34 NB 744a.a42d.8970 cgei-1/1/0/36 91 KND-VKAU-EN1-Z20HS -cgei-1/1/0/36 NB 744a.a42c.d600 cgei-1/1/0/33 99 KND-SAMA-EN1-Z20HS -xxvgei-1/1/0/15 NB d4c1.c893.4350 xgei-0/0/0/7 102 KND-KLK-AN1-ZM8S -xxvgei-1/1/0/16 NB 744a.a430.1540 xxvgei-1/1/0/28 115 KND-PGGL-EN1-Z20HS -xxvgei-1/1/0/17 NB 744a.a42d.6948 xxvgei-1/1/0/16 112 KND-IWEA-EN1-Z20HS -xxvgei-1/1/0/18 NB 744a.a42d.6948 xxvgei-1/1/0/17 112 KND-IWEA-EN1-Z20HS -xxvgei-1/1/0/21 NB d4c1.c893.4350 xgei-0/0/0/1 108 KND-KLK-AN1-ZM8S -xxvgei-1/1/0/22 NB fc44.9f82.1b18 xgei-1/1/0/2 95 MKS-BLB-EN1-Z680H -xxvgei-1/1/0/23 NB 744a.a42c.d600 xxvgei-1/1/0/28 99 KND-SAMA-EN1-Z20HS -xxvgei-1/1/0/24 NB 744a.a432.deb8 xxvgei-1/1/0/28 119 KND-AWOA-EN1-Z20HS -xxvgei-1/1/0/28 NB 744a.a42d.8970 xxvgei-1/1/0/28 91 KND-VKAU-EN1-Z20HS +Local Interface Chassis ID Port ID System Name +gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1 """ -class TopologyLldpParseTests(unittest.TestCase): - def test_parse_cisco_lldp_brief_fallback(self) -> None: +class LldpParserTests(unittest.TestCase): + def test_cisco_brief(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") + self.assertGreaterEqual(len(hits), 1) + self.assertTrue(any(h.remote_name.upper().startswith("R1") for h in hits)) - 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_huawei(self) -> None: + hits = lldp.parse_huawei_lldp(HUAWEI_LLDP) + self.assertGreaterEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name.lower(), "r1") - 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_parse_zte_lldp_brief_lab(self) -> None: - hits = lldp.parse_neighbor_output( - ZTE_LLDP_BRIEF, protocol="lldp", vendor="ZTE", device_type="zte_zxros" - ) - self.assertEqual(len(hits), 11) - self.assertEqual(hits[0].local_port, "cgei-1/1/0/34") - self.assertEqual(hits[0].remote_port, "cgei-1/1/0/36") - self.assertEqual(hits[0].remote_name, "KND-VKAU-EN1-Z20HS") - self.assertEqual(hits[2].local_port, "xxvgei-1/1/0/15") - self.assertEqual(hits[2].remote_port, "xgei-0/0/0/7") - self.assertEqual(hits[2].remote_name, "KND-KLK-AN1-ZM8S") - self.assertEqual(hits[-1].remote_name, "KND-VKAU-EN1-Z20HS") - self.assertEqual(hits[-1].local_port, "xxvgei-1/1/0/28") - - 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 neighbor brief", - "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"), []) + def test_pick_command_lldp_only(self) -> None: + cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios") + self.assertEqual(tag, "lldp") + self.assertIn("lldp", cmd.lower()) -class TopologyServiceTests(unittest.TestCase): - def setUp(self) -> None: +class FabricTopologyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: Base.metadata.create_all(bind=engine) - with engine.begin() as conn: - conn.exec_driver_sql( - "ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_color VARCHAR(32) DEFAULT ''" - ) - conn.exec_driver_sql( - "ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_width INTEGER DEFAULT 0" - ) - conn.exec_driver_sql( - "ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''" - ) + + def setUp(self) -> None: self.db = SessionLocal() - # Clean topology tables between tests - for m in self.db.query(TopologyMap).all(): - svc.delete_map(self.db, m.id) + # Shared SQLite DB across tests — wipe fabric/view state for isolation. + for model in ( + TopoViewEdgeStyle, + TopoViewNode, + TopoView, + TopoFabricEdge, + TopoFabricNode, + TopoDiscoverJobItem, + TopoDiscoverJob, + ): + self.db.query(model).delete() + self.db.commit() 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", - stroke_color="#2563eb", - stroke_width=3, - line_style="dashed", - ) - ], - ), + def test_view_crud_and_positions(self) -> None: + suffix = uuid4().hex[:8] + ne = ManagedNE( + id=f"ne-{suffix}", + name=f"R-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.0.0.{(int(suffix[:2], 16) % 200) + 1}", ) - self.assertEqual(len(graph.nodes), 2) - self.assertEqual(len(graph.edges), 1) - self.assertEqual(graph.edges[0].source, "manual") - self.assertEqual(graph.edges[0].stroke_color, "#2563eb") - self.assertEqual(graph.edges[0].stroke_width, 3) - self.assertEqual(graph.edges[0].line_style, "dashed") + self.db.add(ne) + self.db.commit() - 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_put_preserves_discovered_at_and_rejects_self_loop(self) -> None: - created = svc.create_map(self.db, TopologyMapCreate(name="Preserve", remark="")) - mid = created.id - first = svc.put_graph( - self.db, - mid, - TopologyGraphPut( - nodes=[ - TopologyNodeIn(id="n1", label="A", x=0, y=0), - TopologyNodeIn(id="n2", label="B", x=10, y=0), - ], - edges=[ - TopologyEdgeIn( - id="e1", - source_node_id="n1", - target_node_id="n2", - source="lldp", - source_port="Gi0/0", - target_port="Gi0/1", - ) - ], - ), + view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}")) + graph = svc.add_nodes_to_view( + self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id]) ) - discovered = first.edges[0].discovered_at - self.assertIsNotNone(discovered) - - second = svc.put_graph( + self.assertEqual(len(graph.nodes), 1) + fid = graph.nodes[0].fabric_node_id + graph2 = svc.patch_view_positions( self.db, - mid, - TopologyGraphPut( - nodes=[ - TopologyNodeIn(id="n1", label="A", x=1, y=1), - TopologyNodeIn(id="n2", label="B", x=11, y=1), - ], - edges=[ - TopologyEdgeIn( - id="e1", - source_node_id="n1", - target_node_id="n2", - source="lldp", - source_port="Gi0/0", - target_port="Gi0/1", - stroke_color="#0ea5e9", - ) - ], - ), + view.id, + ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]), ) - self.assertEqual(second.edges[0].discovered_at, discovered) - self.assertEqual(second.edges[0].stroke_color, "#0ea5e9") + self.assertEqual(graph2.nodes[0].x, 120) + self.assertEqual(graph2.nodes[0].y, 80) - with self.assertRaises(HTTPException) as ctx: - svc.put_graph( + summary = svc.get_fabric_summary(self.db) + self.assertGreaterEqual(summary.node_count, 1) + + svc.delete_view(self.db, view.id) + self.db.delete(ne) + self.db.commit() + + def test_lldp_discover_writes_fabric_edge(self) -> None: + suffix = uuid4().hex[:8] + ne_a = ManagedNE( + id=f"nea-{suffix}", + name="R2", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}", + ) + ne_b = ManagedNE( + id=f"neb-{suffix}", + name="R1", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}", + ) + self.db.add(ne_a) + self.db.add(ne_b) + self.db.commit() + # Ensure fabric nodes exist for matching + svc.ensure_fabric_node_for_managed(self.db, ne_a) + svc.ensure_fabric_node_for_managed(self.db, ne_b) + self.db.commit() + + fake_exec = { + "ok": True, + "output": CISCO_LLDP_DETAIL, + "commands": ["show lldp neighbors detail"], + } + with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec): + job = svc.start_discover_job( self.db, - mid, - TopologyGraphPut( - nodes=[TopologyNodeIn(id="n1", label="A", x=0, y=0)], - edges=[ - TopologyEdgeIn( - id="bad", - source_node_id="n1", - target_node_id="n1", - source="manual", - ) - ], + FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1), + ) + # Wait for background thread + for _ in range(50): + out = svc.get_discover_job(self.db, job.id) + if out.status in {"done", "failed"}: + break + time.sleep(0.05) + out = svc.get_discover_job(self.db, job.id) + self.assertEqual(out.status, "done", out.error) + edges = svc.list_fabric_edges(self.db, page=1, page_size=50) + self.assertGreaterEqual(edges["total"], 1) + + self.db.delete(ne_a) + self.db.delete(ne_b) + self.db.commit() + + def test_merge_duplicate_and_project_skips_orphans(self) -> None: + suffix = uuid4().hex[:8] + ne_a = ManagedNE( + id=f"nea-{suffix}", + name="R2", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 10}", + ) + ne_b = ManagedNE( + id=f"neb-{suffix}", + name="R1", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 100}", + ) + self.db.add(ne_a) + self.db.add(ne_b) + self.db.commit() + fa = svc.ensure_fabric_node_for_managed(self.db, ne_a) + fb = svc.ensure_fabric_node_for_managed(self.db, ne_b) + orphan = TopoFabricNode( + id=uuid4().hex, + managed_ne_id=None, + ume_ne_id=None, + name="r1", + ip="", + vendor="", + device_type="", + attrs={"from_lldp_unmatched": True}, + ) + # Simulate raced duplicate of R2 (same managed id not allowed by constraint — + # use name/ip collision path with a second orphan twin). + orphan_r2 = TopoFabricNode( + id=uuid4().hex, + managed_ne_id=None, + ume_ne_id=None, + name="R2", + ip=ne_a.ip_address, + vendor="", + device_type="", + attrs={"from_lldp_unmatched": True}, + ) + self.db.add(orphan) + self.db.add(orphan_r2) + self.db.commit() + # Path: orphan_r2 -- fb -- fa -- orphan (the bug shape) + svc.upsert_fabric_edge( + self.db, a_node_id=orphan_r2.id, b_node_id=fb.id, a_port="g0", b_port="g1", source="lldp" + ) + svc.upsert_fabric_edge( + self.db, a_node_id=fb.id, b_node_id=fa.id, a_port="g2", b_port="g3", source="lldp" + ) + svc.upsert_fabric_edge( + self.db, a_node_id=fa.id, b_node_id=orphan.id, a_port="g4", b_port="g5", source="lldp" + ) + self.db.commit() + + view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}")) + graph = svc.add_nodes_to_view( + self.db, + view.id, + ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]), + ) + self.assertEqual(len(graph.nodes), 4) + + merged = svc.merge_duplicate_fabric_nodes(self.db) + self.assertGreaterEqual(merged["merged"], 2) + self.db.expire_all() + self.assertIsNone(self.db.get(TopoFabricNode, orphan.id)) + self.assertIsNone(self.db.get(TopoFabricNode, orphan_r2.id)) + + # Re-place inventory nodes only, then project should stay at 2. + graph2 = svc.add_nodes_to_view( + self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id]) + ) + # View may still have stale placements pointing at deleted ids — project cleans. + projected = svc.project_fabric_neighbors_to_view(self.db, view.id) + self.assertEqual(len(projected.nodes), 2) + labels = sorted((n.name or n.label).upper() for n in projected.nodes) + self.assertEqual(labels, ["R1", "R2"]) + self.assertGreaterEqual(len(projected.edges), 1) + endpoint_ids = {n.fabric_node_id for n in projected.nodes} + for e in projected.edges: + self.assertIn(e.a_node_id, endpoint_ids) + self.assertIn(e.b_node_id, endpoint_ids) + + self.db.delete(ne_a) + self.db.delete(ne_b) + self.db.commit() + + def test_match_hit_prefers_inventory(self) -> None: + suffix = uuid4().hex[:8] + ne = ManagedNE( + id=f"ne-{suffix}", + name="R1", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 20}", + ) + self.db.add(ne) + self.db.commit() + inv = svc.ensure_fabric_node_for_managed(self.db, ne) + orphan = TopoFabricNode( + id=uuid4().hex, + name="r1", + ip="", + attrs={"from_lldp_unmatched": True}, + ) + self.db.add(orphan) + self.db.commit() + hit = lldp.NeighborHit( + remote_name="r1", + remote_ip="", + local_port="Gi0/0", + remote_port="Gi0/1", + ) + peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self") + self.assertEqual(peer.id, inv.id) + self.db.delete(ne) + self.db.commit() + + def test_lldp_unmatched_creates_ssh_placeholder_ne(self) -> None: + suffix = uuid4().hex[:8] + peer_name = f"Peer-{suffix}" + ne_a = ManagedNE( + id=f"nea-{suffix}", + name=f"Core-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}", + source="manual", + ) + self.db.add(ne_a) + self.db.commit() + svc.ensure_fabric_node_for_managed(self.db, ne_a) + self.db.commit() + + lldp_out = f""" +------------------------------------------------ +Local Intf: Gi0/1 +Chassis id: 707b.5c6e.d130 +Port id: Ethernet1/0/1 +System Name: {peer_name} +Management Addresses: + IP: 198.51.100.200 +""" + fake_exec = { + "ok": True, + "output": lldp_out, + "commands": ["show lldp neighbors detail"], + } + with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec): + job = svc.start_discover_job( + self.db, + FabricDiscoverRequest( + scope="ne_ids", ne_ids=[ne_a.id], concurrency=1, auto_add_unmatched=True ), ) - self.assertEqual(ctx.exception.detail, "edge_self_loop") - svc.delete_map(self.db, mid) + for _ in range(50): + out = svc.get_discover_job(self.db, job.id) + if out.status in {"done", "failed"}: + break + time.sleep(0.05) + out = svc.get_discover_job(self.db, job.id) + self.assertEqual(out.status, "done", out.error) - def test_discover_matches_by_name_and_skips_manual(self) -> None: + placeholders = ( + self.db.query(ManagedNE) + .filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE) + .all() + ) + placeholder = next( + (p for p in placeholders if svc._norm_host(p.name) == svc._norm_host(peer_name)), + None, + ) + self.assertIsNotNone(placeholder) + assert placeholder is not None + self.assertEqual(placeholder.ip_address, "") + self.assertEqual(placeholder.username, "") + self.assertEqual(placeholder.password_enc, "") + self.assertEqual(placeholder.protocol, "ssh") + self.assertEqual(placeholder.device_type, "generic") + self.assertEqual(placeholder.source_ref, "198.51.100.200") + + view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}")) + svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id])) + projected = svc.project_fabric_neighbors_to_view(self.db, view.id) + self.assertEqual(len(projected.nodes), 2) + self.assertGreaterEqual(len(projected.edges), 1) + + self.db.delete(ne_a) + self.db.delete(placeholder) + self.db.commit() + + def test_match_prefers_real_ne_over_webcrt_same_ip(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, + ip = f"198.51.100.{(int(suffix[:2], 16) % 80) + 30}" + real = ManagedNE( + id=f"real-{suffix}", name="R2", vendor="Cisco", device_type="cisco_ios", - ip_address=ip_a, - connect_status="pass", + ip_address=ip, + source="manual", + ) + webcrt = ManagedNE( + id=f"wcrt-{suffix}", + name=f"{ip} (1)", + vendor="Other", + device_type="generic", + ip_address=ip, + source=WEBCRT_NE_SOURCE, + ) + self.db.add(real) + self.db.add(webcrt) + self.db.commit() + inv = svc.ensure_fabric_node_for_managed(self.db, real) + ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt) + self.db.commit() + hit = lldp.NeighborHit(remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1") + peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self") + self.assertEqual(peer.id, inv.id) + self.assertNotEqual(peer.id, ghost.id) + + merged = svc.merge_duplicate_fabric_nodes(self.db) + self.assertGreaterEqual(merged["merged"], 1) + self.db.expire_all() + self.assertIsNone(self.db.get(TopoFabricNode, ghost.id)) + self.db.delete(real) + self.db.delete(webcrt) + self.db.commit() + + def test_neighborhood(self) -> None: + suffix = uuid4().hex[:8] + ne_a = ManagedNE( + id=f"nea-{suffix}", + name=f"A-{suffix}", + ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}", + vendor="ZTE", + device_type="zte_zxros", ) ne_b = ManagedNE( - id=ne_b_id, - name="R1", - vendor="Cisco", - device_type="cisco_ios", - ip_address=ip_b, - connect_status="pass", + id=f"neb-{suffix}", + name=f"B-{suffix}", + ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}", + vendor="ZTE", + device_type="zte_zxros", ) 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( + fa = svc.ensure_fabric_node_for_managed(self.db, ne_a) + fb = svc.ensure_fabric_node_for_managed(self.db, ne_b) + self.db.commit() + svc.upsert_fabric_edge( 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", - ) - ], - ), + a_node_id=fa.id, + b_node_id=fb.id, + a_port="gei-0/1/0/1", + b_port="gei-0/1/0/2", + source="lldp", ) - - 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) - self.assertTrue(out2.results and any(l.action == "added" for l in (out2.results[0].links or []))) - 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.commit() + nb = svc.get_fabric_neighborhood(self.db, fa.id, depth=1) + self.assertEqual(len(nb.nodes), 2) + self.assertEqual(len(nb.edges), 1) self.db.delete(ne_a) self.db.delete(ne_b) self.db.commit() - def test_iter_discover_emits_live_progress_events(self) -> None: - suffix = uuid4().hex[:8] - ne_a_id = f"nea-{suffix}" - ne_b_id = f"neb-{suffix}" - ip_a = f"198.51.100.{(int(suffix[:2], 16) % 100) + 1}" - ip_b = f"198.51.100.{(int(suffix[2:4], 16) % 100) + 101}" + def _pair_nodes(self, suffix: str) -> tuple[TopoFabricNode, TopoFabricNode, ManagedNE, ManagedNE]: ne_a = ManagedNE( - id=ne_a_id, - name="R2", + id=f"nea-{suffix}", + name=f"A-{suffix}", + ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}", vendor="Cisco", device_type="cisco_ios", - ip_address=ip_a, ) ne_b = ManagedNE( - id=ne_b_id, - name="R1", + id=f"neb-{suffix}", + name=f"B-{suffix}", + ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}", vendor="Cisco", device_type="cisco_ios", - ip_address=ip_b, ) self.db.add(ne_a) self.db.add(ne_b) self.db.commit() - created = svc.create_map(self.db, TopologyMapCreate(name=f"Stream-{suffix}")) - mid = created.id - svc.put_graph( + fa = svc.ensure_fabric_node_for_managed(self.db, ne_a) + fb = svc.ensure_fabric_node_for_managed(self.db, ne_b) + self.db.commit() + return fa, fb, ne_a, ne_b + + def test_edge_missing_after_one_absent_cycle(self) -> None: + suffix = uuid4().hex[:8] + fa, fb, ne_a, ne_b = self._pair_nodes(suffix) + edge, _ = svc.upsert_fabric_edge( 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=100, y=0), - ], - edges=[], - ), + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="lldp", ) - fake_exec = { - "ok": True, - "output": CISCO_CDP_DETAIL, - "commands": ["show cdp neighbors detail"], - } - events: list[str] = [] - with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec): - for ev in svc.iter_discover_neighbors( - self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id]) - ): - events.append(str(ev.get("type") or "")) - self.assertEqual(events[0], "start") - self.assertIn("ne_start", events) - self.assertIn("ne_result", events) - self.assertEqual(events[-1], "done") - svc.delete_map(self.db, mid) + self.db.commit() + + newly, purged = svc._apply_missing_and_purge( + self.db, + scanned_ok={fa.id}, + touched_edge_ids=set(), + ) + self.db.commit() + self.assertEqual(newly, 1) + self.assertEqual(purged, 0) + self.db.refresh(edge) + self.assertEqual(edge.status, "missing") + self.assertEqual(int((edge.attrs or {}).get("miss_count") or 0), 1) + self.db.delete(ne_a) self.db.delete(ne_b) self.db.commit() - def test_discover_marks_missing_edges_stale(self) -> None: + def test_edge_purge_after_four_missing_cycles(self) -> None: suffix = uuid4().hex[:8] - ne_a_id = f"nea-{suffix}" - ne_b_id = f"neb-{suffix}" - ip_a = f"203.0.113.{(int(suffix[:2], 16) % 80) + 10}" - ip_b = f"203.0.113.{(int(suffix[2:4], 16) % 80) + 100}" - ne_a = ManagedNE( - id=ne_a_id, - name="R2", - vendor="Cisco", - device_type="cisco_ios", - ip_address=ip_a, - ) - ne_b = ManagedNE( - id=ne_b_id, - name="R1", - vendor="Cisco", - device_type="cisco_ios", - ip_address=ip_b, - ) - self.db.add(ne_a) - self.db.add(ne_b) - self.db.commit() - mid = svc.create_map(self.db, TopologyMapCreate(name=f"Stale-{suffix}")).id - svc.put_graph( + fa, fb, ne_a, ne_b = self._pair_nodes(suffix) + edge, _ = svc.upsert_fabric_edge( 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=100, y=0), - ], - edges=[], - ), + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="lldp", ) - with_neighbors = { - "ok": True, - "output": CISCO_CDP_DETAIL, - "commands": ["show cdp neighbors detail"], - } - empty = {"ok": True, "output": "Total entries displayed: 0\n", "commands": ["show cdp neighbors detail"]} - with patch.object(svc, "execute_managed_ne_commands", return_value=with_neighbors): - out1 = svc.discover_neighbors( - self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id]) + edge_id = edge.id + self.db.commit() + + for cycle in range(1, 5): + newly, purged = svc._apply_missing_and_purge( + self.db, + scanned_ok={fa.id}, + touched_edge_ids=set(), ) - self.assertGreaterEqual(out1.edges_added, 1) - with patch.object(svc, "execute_managed_ne_commands", return_value=empty): - out2 = svc.discover_neighbors( - self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id]) - ) - self.assertGreaterEqual(out2.edges_stale, 1) - graph = svc.get_graph(self.db, mid) - self.assertTrue(any(e.source == "stale" for e in graph.edges)) - svc.delete_map(self.db, mid) + self.db.commit() + if cycle < 4: + self.assertEqual(purged, 0) + row = self.db.get(TopoFabricEdge, edge_id) + self.assertIsNotNone(row) + self.assertEqual(int((row.attrs or {}).get("miss_count") or 0), cycle) + else: + self.assertEqual(purged, 1) + self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) + self.db.delete(ne_a) self.db.delete(ne_b) self.db.commit() - def test_discover_reports_unmatched_neighbors(self) -> None: + def test_peer_replace_marks_old_edge_missing(self) -> None: suffix = uuid4().hex[:8] - ne_a_id = f"nea-{suffix}" - ip_a = f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}" - ne_a = ManagedNE( - id=ne_a_id, - name="R2", + fa, fb, ne_a, ne_b = self._pair_nodes(suffix) + ne_c = ManagedNE( + id=f"nec-{suffix}", + name=f"C-{suffix}", + ip_address=f"198.51.100.{(int(suffix[4:6], 16) % 80) + 50}", vendor="Cisco", device_type="cisco_ios", - ip_address=ip_a, - connect_status="pass", ) - self.db.add(ne_a) + self.db.add(ne_c) self.db.commit() - created = svc.create_map(self.db, TopologyMapCreate(name=f"Unmatch-{suffix}")) - mid = created.id - svc.put_graph( + fc = svc.ensure_fabric_node_for_managed(self.db, ne_c) + self.db.commit() + + old, _ = svc.upsert_fabric_edge( self.db, - mid, - TopologyGraphPut( - nodes=[TopologyNodeIn(id="n1", managed_ne_id=ne_a_id, label="R2", x=0, y=0)], - edges=[], - ), + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="lldp", + ) + new, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fa.id, + b_node_id=fc.id, + a_port="Gi0/0", + b_port="Gi0/2", + source="lldp", ) - 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]) - ) - self.assertEqual(len(out.results), 1) - r = out.results[0] - self.assertTrue(r.ok) - self.assertGreaterEqual(r.unmatched_count, 1) - self.assertTrue(any((u.remote_name or "").upper().startswith("R1") for u in r.unmatched)) - self.assertEqual(r.links, []) - svc.delete_map(self.db, mid) - self.db.delete(ne_a) self.db.commit() + handled = svc._mark_replaced_port_peers( + self.db, + self_id=fa.id, + local_port="Gi0/0", + peer_id=fc.id, + new_edge_id=new.id, + ) + self.db.commit() + self.assertIn(old.id, handled) + self.db.refresh(old) + self.assertEqual(old.status, "missing") + self.assertEqual((old.attrs or {}).get("replaced_by_edge_id"), new.id) + self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1) -class IfnameNormalizeTests(unittest.TestCase): - def test_cisco_huawei_aliases(self) -> None: - self.assertEqual(lldp.normalize_ifname("GigabitEthernet0/0"), "gi0/0") - self.assertEqual(lldp.normalize_ifname("Gi0/0"), "gi0/0") - self.assertEqual(lldp.normalize_ifname("TenGigabitEthernet1/0/1"), "te1/0/1") - self.assertEqual(lldp.normalize_ifname("XGigabitEthernet0/0/1"), "xge0/0/1") - self.assertEqual(lldp.normalize_ifname("Eth-Trunk1"), "eth-trunk1") - self.assertEqual(lldp.normalize_ifname(""), "") + # Same-job: replaced id in touched → no second miss bump. + newly, purged = svc._apply_missing_and_purge( + self.db, + scanned_ok={fa.id}, + touched_edge_ids={new.id, *handled}, + ) + self.db.commit() + self.assertEqual(newly, 0) + self.assertEqual(purged, 0) + self.db.refresh(old) + self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1) - def test_edge_pair_key_aliases_match(self) -> None: - a = svc._edge_pair_key("n1", "n2", "GigabitEthernet0/0", "Gi0/1") - b = svc._edge_pair_key("n1", "n2", "Gi0/0", "GigabitEthernet0/1") - self.assertEqual(a, b) + self.db.delete(ne_a) + self.db.delete(ne_b) + self.db.delete(ne_c) + self.db.commit() - def test_parser_meta_marks_stubs(self) -> None: - key, stub = lldp.parser_meta(vendor="H3C", device_type="hp_comware") - self.assertEqual(key, "h3c") - self.assertTrue(stub) - key2, stub2 = lldp.parser_meta(vendor="Cisco", device_type="cisco_ios") - self.assertEqual(key2, "cisco") - self.assertFalse(stub2) + def test_edge_reactivate_clears_miss_attrs(self) -> None: + suffix = uuid4().hex[:8] + fa, fb, ne_a, ne_b = self._pair_nodes(suffix) + edge, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="lldp", + ) + self.db.commit() + svc._apply_missing_and_purge( + self.db, scanned_ok={fa.id}, touched_edge_ids=set() + ) + self.db.commit() + self.db.refresh(edge) + self.assertEqual(edge.status, "missing") + + edge2, action = svc.upsert_fabric_edge( + self.db, + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="lldp", + ) + self.db.commit() + self.assertEqual(action, "updated") + self.assertEqual(edge2.id, edge.id) + self.assertEqual(edge2.status, "active") + attrs = edge2.attrs or {} + self.assertNotIn("miss_count", attrs) + self.assertNotIn("first_missing_at", attrs) + self.assertNotIn("replaced_by_edge_id", attrs) + + self.db.delete(ne_a) + self.db.delete(ne_b) + self.db.commit() + + def test_scan_fail_does_not_mark_missing(self) -> None: + """Endpoint not in scanned_ok (SSH/parse fail) → leave edge active.""" + suffix = uuid4().hex[:8] + fa, fb, ne_a, ne_b = self._pair_nodes(suffix) + edge, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi0/0", + b_port="Gi0/1", + source="lldp", + ) + self.db.commit() + + newly, purged = svc._apply_missing_and_purge( + self.db, + scanned_ok=set(), # scan failed — nothing judged + touched_edge_ids=set(), + ) + self.db.commit() + self.assertEqual(newly, 0) + self.assertEqual(purged, 0) + self.db.refresh(edge) + self.assertEqual(edge.status, "active") + + # Manual edges never auto-missing. + manual, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fa.id, + b_node_id=fb.id, + a_port="Gi1/0", + b_port="Gi1/1", + source="manual", + ) + self.db.commit() + newly, purged = svc._apply_missing_and_purge( + self.db, + scanned_ok={fa.id}, + touched_edge_ids=set(), + ) + self.db.commit() + self.db.refresh(manual) + self.assertEqual(manual.status, "active") + self.assertEqual(manual.source, "manual") + + self.db.delete(ne_a) + self.db.delete(ne_b) + self.db.commit() if __name__ == "__main__": diff --git a/web/WEB.md b/web/WEB.md index 6715dbe..da2bc61 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -123,19 +123,14 @@ src/ - 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看 - 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元 -## 拓扑管理 +## 拓扑管理(Fabric + View) -- 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形 -- 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C` -- 侧栏网元可点击或拖放到画布;发现默认保留节点坐标(「显示 → 发现后自动布局」可选开启并本机记住) -- 支持对齐、网格吸附、轻量撤销/重做、链路图例 -- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线) -- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名 -- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化 -- 发现进度主区只显示摘要与入口;点「详情」打开列表弹窗,再点设备打开链路复核弹窗 -- 工具栏可搜索定位并高亮;侧栏已上图网元点击可定位 -- 连线模式画布顶提示;侧栏折叠/编辑/删除使用内联 SVG 图标;名称与 IP/端口间隔统一为 ASCII ` / `(避免 Unicode 损坏) -- 链路右键可跳转端口流量(`/network/tasks/port-traffic?ne_id=&source=&ifname=`) +- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP) +- 视图:`topo_view` + `topo_view_node`(坐标);旧 `topology_map/node/edge` 已退役 +- API:`/v1/topology/fabric/*`(summary / nodes / edges / neighborhood / discover job)、`/v1/topology/views*` +- 前端:`@xyflow/react`;侧栏上图、拖拽保存坐标(PATCH positions)、布局(dagre/力导向/网格/环形)、轮询 LLDP job +- MCP:`getTopologySummary`、`queryTopologyNodes/Edges`、`getTopologyNeighborhood`、`runLldpDiscover`、`getLldpDiscoverJob`、`listTopologyViews`、`getTopologyView` +- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO ## WebCRT diff --git a/web/src/App.tsx b/web/src/App.tsx index a0adbcf..cb569d9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { NetworkLayout } from "./pages/network/NetworkLayout"; import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage"; import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage"; import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage"; +import { LldpLinksPage } from "./pages/network/LldpLinksPage"; import { PortTrafficPage } from "./pages/network/PortTrafficPage"; import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPage"; import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage"; @@ -104,7 +105,8 @@ function ProtectedApp() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/config/networkNav.ts b/web/src/config/networkNav.ts index 5bcc146..1d4a067 100644 --- a/web/src/config/networkNav.ts +++ b/web/src/config/networkNav.ts @@ -3,7 +3,7 @@ * Add leaf items here when extending (devices / topology / alarms / tasks). */ -export type NetworkNavGroupId = "detail" | "tasks"; +export type NetworkNavGroupId = "detail" | "topologyMgmt" | "tasks"; export type NetworkNavItem = { id: string; @@ -28,6 +28,18 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [ { id: "configs", path: "/network/configs", labelKey: "network.nav.configs", group: "detail" }, ], }, + { + id: "topologyMgmt", + labelKey: "network.nav.topologyMgmt", + items: [ + { + id: "lldp-links", + path: "/network/topology/lldp", + labelKey: "network.nav.lldpLinks", + group: "topologyMgmt", + }, + ], + }, { id: "tasks", labelKey: "network.nav.tasks", diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index cdba5d5..95fed4f 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -46,8 +46,17 @@ 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, + topologyViews: ["topologyViews"] as const, + topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const, + fabricSummary: ["fabricSummary"] as const, + /** @deprecated */ + topologyMaps: ["topologyViews"] as const, + topologyGraph: (viewId: string) => ["topologyViewGraph", viewId] as const, + lldpCollectDashboard: ["lldpCollectDashboard"] as const, + lldpCollectJobsAll: ["lldpCollectJobs"] as const, + lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const, + lldpCollectJobAll: ["lldpCollectJob"] as const, + lldpCollectJob: (jobId: string) => ["lldpCollectJob", jobId] as const, configSyncDashboard: ["configSyncDashboard"] as const, configSyncPolicy: ["configSyncPolicy"] as const, configSyncCyclesAll: ["configSyncCycles"] as const, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8029cda..5cfbe01 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -32,7 +32,7 @@ const en = { webcrt: "WebCRT", webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)", topology: "Topology", - topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP", + topologyDesc: "Fabric topology views; LLDP physical discovery; MCP paged graph query", portTrafficWall: "Traffic wall", portTrafficWallDesc: "Named traffic boards in dedicated tabs", users: "Users", @@ -48,8 +48,10 @@ const en = { nav: { detail: "Network details", tasks: "Monitoring tasks", + topologyMgmt: "Topology management", devices: "Device list", topology: "Topology", + lldpLinks: "LLDP links", alarms: "Alarms", configs: "Configurations", webcrt: "WebCRT", @@ -225,6 +227,49 @@ const en = { vendor: "Vendor", }, }, + lldpLinks: { + title: "LLDP links", + collectNow: "Collect now", + started: "LLDP collect started", + policyTitle: "Collect policy", + policySaved: "Policy saved", + enabled: "Enable scheduled collect", + autoAddUnmatched: "Create SSH placeholder NEs for unmatched neighbors", + intervalDays: "Interval (days)", + concurrency: "Concurrency", + scope: "Scope", + scopeAll: "All managed NEs", + scopeSelected: "Selected NEs", + savePolicy: "Save policy", + selectedCount: "{{count}} selected", + targetKeywordPh: "Name / IP / vendor", + jobsTitle: "Collect jobs", + jobDetailTitle: "Job details", + kpi: { + nodes: "Fabric nodes", + edges: "Active links", + missing: "Missing", + running: "In progress", + idle: "Idle", + last: "Last job", + nextDue: "Next due", + }, + col: { + source: "Source", + name: "Device", + vendor: "Vendor", + trigger: "Trigger", + scope: "Scope", + status: "Status", + progress: "Progress", + edges: "Edges", + missingDelta: "Missing/purged", + started: "Started", + ended: "Ended", + neighbors: "Neighbors", + error: "Error", + }, + }, configSync: { title: "Config sync", syncNow: "Sync now", @@ -924,7 +969,6 @@ const en = { }, newSession: { title: "New Session", - hint: "Enter host and protocol. SSH prompts for username/password (editable on retry); Telnet logs in interactively. Duplicate names become (1), (2)…; same IP is allowed.", connect: "Connect", connecting: "Connecting…", protocol: "Protocol", @@ -1154,7 +1198,7 @@ const en = { sourcePort: "Local port", targetPort: "Remote port", canvasHint: - "Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.", + "Add NEs from the palette, drag then save positions; LLDP discovery writes the global fabric. Blue dashed=discovered, red=stale.", selected: "Selected", selectedEdge: "Selected edge", selectedCount: "{{count}} selected", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 0ddd074..7939377 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -32,7 +32,7 @@ const zh = { webcrt: "WebCRT", webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)", topology: "拓扑管理", - topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路", + topologyDesc: "Fabric 拓扑视图;LLDP 发现物理链路,MCP 可分页拉图", portTrafficWall: "流量大屏", portTrafficWallDesc: "独立页签打开命名流量大屏", users: "用户管理", @@ -48,8 +48,10 @@ const zh = { nav: { detail: "网络详情", tasks: "监控任务", + topologyMgmt: "拓扑管理", devices: "设备列表", topology: "拓扑信息", + lldpLinks: "LLDP 链路", alarms: "告警信息", configs: "配置信息", webcrt: "WebCRT", @@ -225,6 +227,49 @@ const zh = { vendor: "厂商", }, }, + lldpLinks: { + title: "LLDP 链路", + collectNow: "立即采集", + started: "已启动 LLDP 采集", + policyTitle: "采集策略", + policySaved: "策略已保存", + enabled: "启用周期调度", + autoAddUnmatched: "未纳管邻居建 SSH 占位网元", + intervalDays: "周期(天)", + concurrency: "并发", + scope: "范围", + scopeAll: "全部纳管网元", + scopeSelected: "指定网元", + savePolicy: "保存策略", + selectedCount: "已选 {{count}} 台", + targetKeywordPh: "名称 / IP / 厂商", + jobsTitle: "采集任务", + jobDetailTitle: "任务明细", + kpi: { + nodes: "Fabric 网元", + edges: "活跃链路", + missing: "未发现", + running: "进行中", + idle: "空闲", + last: "最近任务", + nextDue: "下次调度", + }, + col: { + source: "来源", + name: "设备", + vendor: "厂商", + trigger: "触发", + scope: "范围", + status: "状态", + progress: "进度", + edges: "链路", + missingDelta: "未发现/清理", + started: "开始", + ended: "结束", + neighbors: "邻居", + error: "错误", + }, + }, configSync: { title: "配置同步", syncNow: "立即全量同步", @@ -921,7 +966,6 @@ const zh = { }, newSession: { title: "新建会话", - hint: "填写主机与协议后连接。SSH 弹出用户名/密码(可改);Telnet 进终端交互登录。同名会话自动加 (1)、(2)…,允许相同 IP。", connect: "连接", connecting: "连接中…", protocol: "协议", @@ -1151,7 +1195,7 @@ const zh = { sourcePort: "本端端口", targetPort: "对端端口", canvasHint: - "工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。", + "从网元库上图,拖拽后保存坐标;LLDP 发现写入全局 Fabric。蓝虚线=发现链路,红虚线=stale。", selected: "已选节点", selectedEdge: "已选链路", selectedCount: "已选 {{count}}", diff --git a/web/src/index.css b/web/src/index.css index 686f9c8..275e2e9 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3884,6 +3884,22 @@ pre { align-items: center; gap: 6px; justify-content: flex-end; + flex-shrink: 0; +} + +.topo-toolbar__actions > .btn, +.topo-toolbar__group > .btn { + width: auto; + flex: 0 0 auto; + height: 30px; + padding: 0 10px; + font-size: 12px; + line-height: 1; +} + +.topo-toolbar__group > select.topo-toolbar__find { + width: auto; + min-width: 140px; } .topo-toolbar__group { diff --git a/web/src/pages/ConfigSyncPage.tsx b/web/src/pages/ConfigSyncPage.tsx index 92f61ff..cdf79e9 100644 --- a/web/src/pages/ConfigSyncPage.tsx +++ b/web/src/pages/ConfigSyncPage.tsx @@ -119,9 +119,25 @@ export function ConfigSyncPage() { history_keep: historyKeep, selected_targets: Object.values(selectedMap), }), - onSuccess: async () => { + onSuccess: async (saved) => { + // Apply server response immediately — do NOT flip policyHydrated false then + // rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug). + setEnabled(Boolean(saved.enabled)); + setIntervalDays(Number(saved.interval_days || 3)); + setConcurrency(Number(saved.concurrency || 5)); + setScopeMode(saved.scope_mode === "selected" ? "selected" : "all"); + setHistoryKeep(Number(saved.history_keep ?? 3)); + const map: Record = {}; + for (const ref of saved.selected_targets || []) { + map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id }; + } + setSelectedMap(map); + setPolicyHydrated(true); + queryClient.setQueryData(queryKeys.configSyncDashboard, (prev: unknown) => { + if (!prev || typeof prev !== "object") return prev; + return { ...(prev as object), policy: saved }; + }); showOk(t("configSync.policySaved")); - setPolicyHydrated(false); await refresh(); }, onError: (err) => showError(String(err)), diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 80d079a..d7d69ab 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ReactFlow, @@ -23,16 +23,23 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { + addTopologyViewNodes, + createFabricManualEdge, createTopologyMap, deleteTopologyMap, - discoverTopologyNeighborsStream, + fetchLldpDiscoverJob, fetchManagedNe, fetchTopologyGraph, fetchTopologyMaps, fetchUmeNe, - putTopologyGraph, + patchTopologyEdgeStyle, + patchTopologyPositions, + projectTopologyNeighbors, + removeTopologyViewNodes, + startLldpDiscover, updateTopologyMap, } from "../services/api"; +import type { TopologyDiscoverJob, TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../types"; import { queryKeys } from "../constants/queryKeys"; import { HelpHint } from "../components/HelpHint"; import { useI18n } from "../i18n"; @@ -399,27 +406,32 @@ function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge { return { ...edge, style, markerEnd: edgeMarker(style.stroke) }; } -function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defaults: EdgeDefaults) { +function graphToFlow( + nodes: TopologyViewNodeItem[], + edges: TopologyViewEdgeItem[], + defaults: EdgeDefaults, +) { const rfNodes: Node[] = nodes.map((n) => ({ - id: n.id, + id: n.fabric_node_id, type: "neNode", position: { x: n.x || 0, y: n.y || 0 }, data: { - label: n.label || n.ne_name || n.ne_ip || n.id, + label: n.label || n.name || n.ip || n.fabric_node_id, managed_ne_id: n.managed_ne_id || "", ume_ne_id: n.ume_ne_id || "", - ne_ip: n.ne_ip || "", + ne_ip: n.ip || "", vendor: n.vendor || "", connect_status: n.connect_status || "", }, })); const rfEdges: Edge[] = edges.map((e) => { - const src = e.source || "manual"; - const label = [e.source_port, e.target_port].filter(Boolean).join(SEP); + const src = + e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual"; + const label = [e.a_port, e.b_port].filter(Boolean).join(SEP); const data: EdgeStyleData = { source: src, - source_port: e.source_port || "", - target_port: e.target_port || "", + source_port: e.a_port || "", + target_port: e.b_port || "", stroke_color: e.stroke_color || "", stroke_width: Number(e.stroke_width || 0), line_style: e.line_style || "", @@ -428,8 +440,8 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau return withEdgeVisual( { id: e.id, - source: e.source_node_id, - target: e.target_node_id, + source: e.a_node_id, + target: e.b_node_id, type: "straight", label: label || undefined, animated: false, @@ -441,32 +453,25 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau 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) => { - const data = (e.data || {}) as EdgeStyleData; - return { - id: e.id, - source_node_id: e.source, - target_node_id: e.target, - source_port: String(data.source_port || ""), - target_port: String(data.target_port || ""), - source: String(data.source || "manual"), - stroke_color: String(data.stroke_color || ""), - stroke_width: Number(data.stroke_width || 0), - line_style: String(data.line_style || ""), - discovered_at: data.discovered_at ?? null, - }; - }), - }; +function flowToPositions(nodes: Node[]) { + return nodes.map((n) => ({ + fabric_node_id: n.id, + x: n.position.x, + y: n.position.y, + label: n.data.label || "", + })); +} + +function applyViewGraph( + graph: TopologyViewGraph, + defaults: EdgeDefaults, + setNodes: (ns: Node[]) => void, + setEdges: (es: Edge[]) => void, +) { + const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults); + setNodes(rfNodes); + setEdges(rfEdges); + return { rfNodes, rfEdges }; } export function TopologyPage() { @@ -731,7 +736,7 @@ export function TopologyPage() { window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40); if (opts?.persist && mapId) { try { - const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges)); + const graph = await patchTopologyPositions(mapId, flowToPositions(next)); clearDirty(); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); } catch (err) { @@ -804,7 +809,7 @@ export function TopologyPage() { }); const saveMut = useMutation({ - mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)), + mutationFn: () => patchTopologyPositions(mapId, flowToPositions(nodes)), onSuccess: async (graph) => { clearDirty(); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); @@ -817,10 +822,12 @@ export function TopologyPage() { const runDiscover = useCallback( async (neIds?: string[]) => { if (!mapId || discovering) return; + // Topology canvas: single-NE (or explicit id list) only — bulk collect lives under Network → LLDP. const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean); - discoverAbortRef.current?.abort(); - const ac = new AbortController(); - discoverAbortRef.current = ac; + if (!filterIds.length) { + showError(t("topology.discoverOneNeedNe")); + return; + } setDiscoverOpen(true); setDiscovering(true); setDiscoverReport(null); @@ -830,13 +837,11 @@ export function TopologyPage() { setDiscoverDetail(null); setDiscoverListOpen(false); const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id)); - const scoped = filterIds.length - ? scannable.filter( - (n) => - filterIds.includes(String(n.data.managed_ne_id || "")) || - filterIds.includes(String(n.data.ume_ne_id || "")), - ) - : scannable; + const scoped = scannable.filter( + (n) => + filterIds.includes(String(n.data.managed_ne_id || "")) || + filterIds.includes(String(n.data.ume_ne_id || "")), + ); setDiscoverProgress({ index: 0, total: scoped.length, @@ -847,71 +852,76 @@ export function TopologyPage() { }); try { if (dirtyRef.current) { - await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)); + await patchTopologyPositions(mapId, flowToPositions(nodes)); clearDirty(); } - const out = await discoverTopologyNeighborsStream( - mapId, - { - protocol: "auto", - ...(filterIds.length ? { ne_ids: filterIds } : {}), - }, - { - onStart: (ev) => { - setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 })); - }, - onNeStart: (ev) => { - setDiscoverProgress((p) => ({ - ...p, - index: ev.index, - total: ev.total, - neName: ev.ne_name || ev.ne_ip || ev.ne_id, - neIp: ev.ne_ip, - })); - }, - onNeResult: (ev) => { - if (ev.result) { - setDiscoverLiveResults((prev) => [...prev, ev.result]); - } - setDiscoverProgress((p) => ({ - ...p, - index: ev.index, - total: ev.total, - edgesAdded: ev.edges_added, - edgesUpdated: ev.edges_updated, - })); - }, - }, - ac.signal, - ); - if (out.graph) { - queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph); - let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults); - let didAutoLayout = false; - if (autoLayoutAfterDiscover && rfNodes.length > 1) { - rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); - didAutoLayout = true; - try { - const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges)); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - clearDirty(); - } catch { - markDirty(); - } - } else { - clearDirty(); - } - historyLockRef.current = true; - setNodes(rfNodes); - setEdges(rfEdges); - historyLockRef.current = false; - // Only refit when layout changed; otherwise keep the user's viewport. - if (didAutoLayout) { - window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50); - } + const ne_ids = filterIds; + if (!scoped.length) { + throw new Error(t("topology.discoverOneNeedNe")); } + const jobStart = await startLldpDiscover({ + scope: "ne_ids", + ne_ids, + concurrency: 4, + auto_add_unmatched: true, + trigger_mode: "topology", + }); + let job: TopologyDiscoverJob = jobStart; + for (let i = 0; i < 600; i++) { + await new Promise((r) => window.setTimeout(r, 500)); + job = await fetchLldpDiscoverJob(jobStart.id); + setDiscoverProgress((p) => ({ + ...p, + index: job.done, + total: job.total || p.total, + edgesAdded: job.edges_added, + edgesUpdated: job.edges_updated, + neName: job.items?.[job.items.length - 1]?.ne_name || p.neName, + neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp, + })); + setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]); + if (job.status === "done" || job.status === "failed") break; + } + if (job.status === "failed") { + throw new Error(job.error || "discover_failed"); + } + const projected = await projectTopologyNeighbors(mapId); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected); + let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults); + let didAutoLayout = false; + if (autoLayoutAfterDiscover && rfNodes.length > 1) { + rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); + didAutoLayout = true; + try { + const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes)); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + clearDirty(); + } catch { + markDirty(); + } + } else { + clearDirty(); + } + historyLockRef.current = true; + setNodes(rfNodes); + setEdges(rfEdges); + historyLockRef.current = false; + if (didAutoLayout) { + window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50); + } + const out: TopologyDiscoverOut = { + map_id: mapId, + protocol: "lldp", + scanned: job.done, + edges_added: job.edges_added, + edges_updated: job.edges_updated, + edges_stale: job.edges_stale, + results: (job.items || []) as TopologyDiscoverNeResult[], + graph: projected, + }; setDiscoverReport(out); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); + await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary }); showOk( t("topology.discovered") .replace("{{added}}", String(out.edges_added)) @@ -919,15 +929,13 @@ export function TopologyPage() { .replace("{{stale}}", String(out.edges_stale || 0)), ); } catch (err) { - if (ac.signal.aborted) return; setDiscoverError(String(err)); showError(t("topology.discoverFail").replace("{{detail}}", String(err))); } finally { - if (discoverAbortRef.current === ac) discoverAbortRef.current = null; setDiscovering(false); } }, - [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults], + [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults, clearDirty, markDirty], ); const discoverResults = discoverReport?.results?.length @@ -989,91 +997,84 @@ export function TopologyPage() { const onConnect = useCallback( (connection: Connection) => { - if (!isValidConnection(connection)) return; + if (!mapId || !isValidConnection(connection)) return; pushHistory(); - markDirty(); connectClickRef.current = null; - const data: EdgeStyleData = { - source: "manual", - source_port: "", - target_port: "", - stroke_color: "", - stroke_width: 0, - line_style: "", - discovered_at: null, - }; - setEdges((eds) => - addEdge( - withEdgeVisual( - { - ...connection, - id: newId(), - type: "straight", - data, - }, - edgeDefaults, - ), - eds, - ), - ); + void (async () => { + try { + await createFabricManualEdge({ + a_node_id: String(connection.source || ""), + b_node_id: String(connection.target || ""), + a_port: "", + b_port: "", + }); + const graph = await fetchTopologyGraph(mapId); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + historyLockRef.current = true; + applyViewGraph(graph, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + } catch (err) { + showError(String(err)); + } + })(); }, - [setEdges, pushHistory, edgeDefaults, markDirty, isValidConnection], + [setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError], ); const addNodeAt = useCallback( - (item: PaletteItem, position: { x: number; y: number }) => { + async (item: PaletteItem, position: { x: number; y: number }) => { if (!mapId) { showError(t("topology.selectMap")); return; } - if (item.source === "managed") { - if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return; - const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id); - if (!ne) return; - pushHistory(); - markDirty(); - setNodes((prev) => [ - ...prev, - { - id: newId(), - type: "neNode", - position, - 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, + try { + if (item.source === "managed") { + if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return; + const graph = await addTopologyViewNodes(mapId, { managed_ne_ids: [item.managed_ne_id] }); + const added = graph.nodes.find((n) => n.managed_ne_id === item.managed_ne_id); + if (added) { + await patchTopologyPositions(mapId, [ + { + fabric_node_id: added.fabric_node_id, + x: position.x, + y: position.y, + label: added.label || added.name || "", + }, + ]); + } + const refreshed = await fetchTopologyGraph(mapId); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed); + historyLockRef.current = true; + applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + return; + } + if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return; + const graph = await addTopologyViewNodes(mapId, { ume_ne_ids: [item.ume_ne_id] }); + const added = graph.nodes.find((n) => n.ume_ne_id === item.ume_ne_id); + if (added) { + await patchTopologyPositions(mapId, [ + { + fabric_node_id: added.fabric_node_id, + x: position.x, + y: position.y, + label: added.label || added.name || "", }, - }, - ]); - return; + ]); + } + const refreshed = await fetchTopologyGraph(mapId); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed); + historyLockRef.current = true; + applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + } catch (err) { + showError(String(err)); } - if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return; - const ne = (umeQuery.data?.items || []).find((x) => x.ne_id === item.ume_ne_id); - if (!ne) return; - const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim(); - pushHistory(); - markDirty(); - setNodes((prev) => [ - ...prev, - { - id: newId(), - type: "neNode", - position, - data: { - label: name, - managed_ne_id: "", - ume_ne_id: ne.ne_id, - ne_ip: ne.ip_address || "", - vendor: "ZTE", - connect_status: ne.connection_status || "", - }, - }, - ]); }, - [mapId, nodes, neQuery.data, umeQuery.data, setNodes, pushHistory, showError, t], + [mapId, nodes, setNodes, setEdges, showError, t, queryClient, edgeDefaults, clearDirty], ); const addManagedNeToCanvas = (ne: ManagedNeItem) => { @@ -1161,9 +1162,8 @@ export function TopologyPage() { >, opts?: { skipHistory?: boolean }, ) => { - if (!selectedEdgeId) return; + if (!selectedEdgeId || !mapId) return; if (!opts?.skipHistory) pushHistory(); - markDirty(); setEdges((eds) => eds.map((e) => { if (e.id !== selectedEdgeId) return e; @@ -1181,8 +1181,17 @@ export function TopologyPage() { return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults); }), ); + const edge = edges.find((e) => e.id === selectedEdgeId); + const prev = (edge?.data || {}) as EdgeStyleData; + void patchTopologyEdgeStyle(mapId, { + fabric_edge_id: selectedEdgeId, + stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", + stroke_width: + patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), + line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", + }).catch((err) => showError(String(err))); }, - [selectedEdgeId, setEdges, pushHistory, edgeDefaults, markDirty], + [selectedEdgeId, mapId, edges, setEdges, pushHistory, edgeDefaults, showError], ); const renameSelectedNode = useCallback(() => { @@ -1202,13 +1211,6 @@ export function TopologyPage() { setCtxMenu(null); }, [selectedNode, t, pushHistory, markDirty, setNodes]); - const staleEdgeCount = useMemo( - () => - edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale") - .length, - [edges], - ); - const closeCtxMenu = useCallback(() => setCtxMenu(null), []); useEffect(() => { @@ -1251,19 +1253,29 @@ export function TopologyPage() { setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges]); - const removeSelected = useCallback(() => { - const nodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)); + const removeSelected = useCallback(async () => { + if (!mapId) return; + const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id); const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)); - if (nodeIds.size === 0 && edgeIds.size === 0) return; + if (!nodeIds.length && !edgeIds.size) return; pushHistory(); - markDirty(); - setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id))); - setEdges((es) => - es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)), - ); - setSelectedEdgeId(null); - closeCtxMenu(); - }, [nodes, edges, setNodes, setEdges, pushHistory, closeCtxMenu]); + try { + if (nodeIds.length) { + const graph = await removeTopologyViewNodes(mapId, nodeIds); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + historyLockRef.current = true; + applyViewGraph(graph, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + } else if (edgeIds.size) { + setEdges((eds) => eds.filter((e) => !edgeIds.has(e.id))); + markDirty(); + } + setSelectedEdgeId(null); + } catch (err) { + showError(String(err)); + } + }, [mapId, nodes, edges, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, markDirty, showError]); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -1350,18 +1362,6 @@ export function TopologyPage() { closeCtxMenu(); }; - const removeStaleEdges = () => { - const n = staleEdgeCount; - if (n <= 0) return; - pushHistory(); - markDirty(); - setEdges((es) => - es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"), - ); - setSelectedEdgeId(null); - showOk(t("topology.staleRemoved").replace("{{count}}", String(n))); - }; - const openWebcrtFor = (node: Node | null) => { closeCtxMenu(); const managedId = node?.data.managed_ne_id; @@ -1894,23 +1894,6 @@ export function TopologyPage() { ? t("topology.saveDirty") : t("topology.save")} - - - -
  • + + + + +
    +
    +
    +
    {t("lldpLinks.kpi.nodes")}
    +
    {dash?.fabric_node_count ?? "—"}
    +
    +
    +
    {t("lldpLinks.kpi.edges")}
    +
    {dash?.fabric_edge_active ?? "—"}
    +
    +
    +
    {t("lldpLinks.kpi.missing")}
    +
    {dash?.fabric_edge_stale ?? "—"}
    +
    +
    +
    {t("lldpLinks.kpi.running")}
    +
    + {running + ? `${running.status} · ${running.done}/${running.total}` + : t("lldpLinks.kpi.idle")} +
    +
    +
    +
    {t("lldpLinks.kpi.last")}
    +
    + {last + ? `${last.status} · +${last.edges_added} / ~${last.edges_updated}` + : t("common.empty")} +
    +
    +
    +
    {t("lldpLinks.kpi.nextDue")}
    +
    + {dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")} +
    +
    +
    +
    + +
    +

    {t("lldpLinks.policyTitle")}

    +
    + + + + + + +
    + + {scopeMode === "selected" ? ( +
    +

    {t("lldpLinks.selectedCount", { count: String(selectedCount) })}

    +
    + { + setTargetKeyword(e.target.value); + setTargetPage(1); + }} + /> +
    + + + + + + + + + + + {(targetsQuery.data?.items ?? []).map((row) => { + const source = row.source === "ume" ? "ume" : "managed"; + const key = `${source}:${row.id}`; + return ( + + + + + + + + ); + })} + +
    + {t("lldpLinks.col.source")}{t("lldpLinks.col.name")}IP{t("lldpLinks.col.vendor")}
    + toggleTarget(row)} + /> + {source}{row.name}{row.ip_address}{row.vendor || "-"}
    +
    + + + {t("common.pagerMeta", { + total: String(targetsQuery.data?.total ?? 0), + page: String(targetPage), + pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)), + })} + + +
    +
    + ) : null} +
    + +

    {t("lldpLinks.jobsTitle")}

    +
    + + + + + + + + + + + + + + + + {jobs.map((job) => { + const open = expandedJobId === job.id; + return ( + + + + + + + + + + + + + ); + })} + {!jobs.length ? ( + + + + ) : null} + +
    + ID{t("lldpLinks.col.trigger")}{t("lldpLinks.col.scope")}{t("lldpLinks.col.status")}{t("lldpLinks.col.progress")}{t("lldpLinks.col.edges")}{t("lldpLinks.col.missingDelta")}{t("lldpLinks.col.started")}{t("lldpLinks.col.ended")}
    + + {job.id.slice(0, 8)}{job.trigger_mode}{job.scope}{job.status} + {job.done}/{job.total} + + +{job.edges_added} / ~{job.edges_updated} + {job.edges_stale || 0}{job.started_at ? formatSystemTime(job.started_at) : "—"}{job.ended_at ? formatSystemTime(job.ended_at) : "—"}
    + {t("common.empty")} +
    +
    +
    + + + {t("common.pagerMeta", { + total: String(jobTotal), + page: String(jobPage), + pages: String(jobPages), + })} + + +
    + + {expandedJobId ? ( +
    +

    {t("lldpLinks.jobDetailTitle")}

    + {jobDetailQuery.isLoading ?

    …

    : null} + {jobDetailQuery.data?.error ? ( +

    + {jobDetailQuery.data.error} +

    + ) : null} + + + + + + + + + + + + + {detailItems.map((it) => { + const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0); + return ( + + + + + + + + + + ); + })} + {!detailItems.length && !jobDetailQuery.isLoading ? ( + + + + ) : null} + +
    {t("lldpLinks.col.name")}IP{t("lldpLinks.col.status")}{t("lldpLinks.col.neighbors")}{t("lldpLinks.col.edges")}{t("lldpLinks.col.error")} +
    {it.ne_name || it.ne_id || "—"}{it.ne_ip || "—"} + {!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"} + {it.neighbors} + +{it.edges_added} / ~{it.edges_updated} + {unmatchedCount > 0 + ? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}` + : ""} + {it.error || "—"} + +
    + {t("common.empty")} +
    +
    + ) : null} + + {itemDetail ? ( +
    setItemDetail(null)}> +
    e.stopPropagation()} + > +
    +
    +

    + {itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id} +

    +

    + {[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)} +

    +
    + +
    + + {!itemDetail.ok ? ( +

    + {itemDetail.error || t("topology.discoverNeFail")} +

    + ) : null} + {itemDetail.parser_stub ? ( +

    + {t("topology.discoverParserStub").replace( + "{{parser}}", + itemDetail.parser_key || "unknown", + )} +

    + ) : null} + +
    + + {t("topology.discoverNeOk") + .replace("{{neighbors}}", String(itemDetail.neighbors || 0)) + .replace("{{added}}", String(itemDetail.edges_added || 0)) + .replace("{{updated}}", String(itemDetail.edges_updated || 0))} + +
    + +

    + {t("topology.discoverUnmatchedTitle").replace( + "{{count}}", + String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0), + )} +

    + {(itemDetail.unmatched || []).length === 0 ? ( +

    {t("topology.discoverUnmatchedEmpty")}

    + ) : ( +
    + + + + + + + + + + {(itemDetail.unmatched || []).map((u, idx) => ( + + + + + + ))} + +
    {t("topology.discoverColRemote")}{t("topology.discoverColLocalPort")}{t("topology.discoverColRemotePort")}
    + {(u.remote_name || u.remote_ip || "?").trim()} + {u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""} + {u.local_port || "?"}{u.remote_port || "?"}
    +
    + )} + + {itemDetail.raw_preview ? ( + <> +

    {t("topology.discoverRawPreview")}

    +
    {itemDetail.raw_preview}
    + + ) : null} + +
    + +
    +
    +
    + ) : null} + + ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 4697846..d206718 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -18,11 +18,13 @@ import type { CliMeta, CliTargetListResponse, UmeCliOverrideItem, - TopologyDiscoverOut, - TopologyDiscoverNeResult, - TopologyDiscoverStreamHandlers, - TopologyGraph, - TopologyMapItem, + FabricSummary, + TopologyDiscoverJob, + TopologyViewGraph, + TopologyViewItem, + LldpCollectDashboard, + LldpCollectJobSummary, + LldpCollectPolicy, ConfigSyncCycle, ConfigSyncDashboard, ConfigSyncPolicy, @@ -846,158 +848,110 @@ export const fetchUmeCurrentAlarms = (params: { ); }; -export const fetchTopologyMaps = () => - apiGet<{ total: number; items: TopologyMapItem[] }>("/v1/topology/maps"); +export const fetchTopologyViews = () => + apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views"); -export const createTopologyMap = (body: { name: string; remark?: string }) => - apiPost("/v1/topology/maps", body); +export const createTopologyView = (body: { name: string; remark?: string; filter?: Record }) => + apiPost("/v1/topology/views", body); -export const fetchTopologyGraph = (mapId: string) => - apiGet(`/v1/topology/maps/${encodeURIComponent(mapId)}`); +export const fetchTopologyViewGraph = (viewId: string) => + apiGet(`/v1/topology/views/${encodeURIComponent(viewId)}`); -export const updateTopologyMap = (mapId: string, body: { name?: string; remark?: string }) => - apiPatch(`/v1/topology/maps/${encodeURIComponent(mapId)}`, body); +export const updateTopologyView = ( + viewId: string, + body: { name?: string; remark?: string; filter?: Record; viewport?: Record }, +) => apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}`, body); -export const deleteTopologyMap = (mapId: string) => - apiDelete<{ ok: boolean; map_id: string; deleted: boolean }>( - `/v1/topology/maps/${encodeURIComponent(mapId)}`, +export const deleteTopologyView = (viewId: string) => + apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>( + `/v1/topology/views/${encodeURIComponent(viewId)}`, ); -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; - stroke_color?: string; - stroke_width?: number; - line_style?: string; - discovered_at?: string | null; - }>; - }, -) => apiPut(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body); - -export const discoverTopologyNeighbors = ( - mapId: string, - body?: { protocol?: string; ne_ids?: string[] }, +export const patchTopologyPositions = ( + viewId: string, + positions: Array<{ + fabric_node_id: string; + x?: number; + y?: number; + label?: string; + locked?: boolean; + }>, ) => - apiPost( - `/v1/topology/maps/${encodeURIComponent(mapId)}/discover`, - body || {}, - ); - -function parseSseChunks(buffer: string): { events: Array<{ event: string; data: string }>; rest: string } { - const events: Array<{ event: string; data: string }> = []; - let rest = buffer; - while (true) { - const sep = rest.indexOf("\n\n"); - if (sep < 0) break; - const raw = rest.slice(0, sep); - rest = rest.slice(sep + 2); - let event = "message"; - const dataLines: string[] = []; - for (const line of raw.split("\n")) { - if (line.startsWith("event:")) event = line.slice(6).trim(); - else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); - } - if (dataLines.length) events.push({ event, data: dataLines.join("\n") }); - } - return { events, rest }; -} - -export async function discoverTopologyNeighborsStream( - mapId: string, - body: { protocol?: string; ne_ids?: string[] } | undefined, - handlers: TopologyDiscoverStreamHandlers, - signal?: AbortSignal, -): Promise { - const path = `/v1/topology/maps/${encodeURIComponent(mapId)}/discover/stream`; - const res = await fetch(path, { - method: "POST", - headers: authHeaders({ - accept: "text/event-stream", - "content-type": "application/json", - }), - body: JSON.stringify(body || {}), - signal, + apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, { + positions, }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(text || `discover_stream_failed:${res.status}`); - } - if (!res.body) throw new Error("discover_stream_empty_body"); - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - let finalReport: TopologyDiscoverOut | null = null; +export const addTopologyViewNodes = ( + viewId: string, + body: { + managed_ne_ids?: string[]; + ume_ne_ids?: string[]; + fabric_node_ids?: string[]; + layout?: string; + }, +) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const parsed = parseSseChunks(buf); - buf = parsed.rest; - for (const item of parsed.events) { - let payload: Record = {}; - try { - payload = JSON.parse(item.data) as Record; - } catch { - continue; - } - const type = String(payload.type || item.event || ""); - if (type === "start") { - handlers.onStart?.({ - map_id: String(payload.map_id || ""), - protocol: String(payload.protocol || ""), - total: Number(payload.total || 0), - }); - } else if (type === "ne_start") { - handlers.onNeStart?.({ - index: Number(payload.index || 0), - total: Number(payload.total || 0), - ne_id: String(payload.ne_id || ""), - ne_name: String(payload.ne_name || ""), - ne_ip: String(payload.ne_ip || ""), - }); - } else if (type === "ne_result") { - handlers.onNeResult?.({ - index: Number(payload.index || 0), - total: Number(payload.total || 0), - result: payload.result as TopologyDiscoverNeResult, - edges_added: Number(payload.edges_added || 0), - edges_updated: Number(payload.edges_updated || 0), - }); - } else if (type === "done") { - finalReport = payload.report as TopologyDiscoverOut; - if (finalReport) handlers.onDone?.(finalReport); - } else if (type === "error") { - const detail = String(payload.detail || "discover_stream_error"); - handlers.onError?.(detail); - throw new Error(detail); - } - } - } +export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) => + apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, { + fabric_node_ids: fabricNodeIds, + }); - if (!finalReport) throw new Error("discover_stream_incomplete"); - return finalReport; -} +export const projectTopologyNeighbors = (viewId: string) => + apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, {}); + +export const patchTopologyEdgeStyle = ( + viewId: string, + body: { fabric_edge_id: string; stroke_color?: string; stroke_width?: number; line_style?: string }, +) => + apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body); + +export const fetchFabricSummary = () => apiGet("/v1/topology/fabric/summary"); + +export const startLldpDiscover = (body?: { + scope?: "all_inventory" | "ne_ids"; + ne_ids?: string[]; + auto_add_unmatched?: boolean; + concurrency?: number; + trigger_mode?: "manual" | "schedule" | "topology"; +}) => apiPost("/v1/topology/fabric/discover", body || {}); + +export const fetchLldpDiscoverJob = (jobId: string) => + apiGet(`/v1/topology/fabric/discover/${encodeURIComponent(jobId)}`); + +export const fetchLldpCollectDashboard = () => + apiGet("/v1/topology/lldp-collect/dashboard"); + +export const updateLldpCollectPolicy = (body: Partial) => + apiPut("/v1/topology/lldp-collect/policy", body); + +export const startLldpCollect = () => + apiPost<{ ok: boolean; job: TopologyDiscoverJob }>("/v1/topology/lldp-collect/start", {}); + +export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number }) => { + const p = new URLSearchParams(); + p.set("page", String(Math.max(1, Number(params.page || 1)))); + p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20))))); + return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>( + `/v1/topology/lldp-collect/jobs?${p.toString()}`, + ); +}; + +export const fetchLldpCollectJob = (jobId: string) => + apiGet(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`); + +export const createFabricManualEdge = (body: { + a_node_id: string; + b_node_id: string; + a_port?: string; + b_port?: string; +}) => apiPost<{ ok: boolean; action: string; edge: Record }>("/v1/topology/fabric/edges", body); + +/** Back-compat aliases used by older call sites during cutover. */ +export const fetchTopologyMaps = fetchTopologyViews; +export const createTopologyMap = createTopologyView; +export const fetchTopologyGraph = fetchTopologyViewGraph; +export const updateTopologyMap = updateTopologyView; +export const deleteTopologyMap = deleteTopologyView; export const fetchConfigSyncDashboard = () => apiGet("/v1/config-sync/dashboard"); diff --git a/web/src/types.ts b/web/src/types.ts index d6ad4c6..3ae57d9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -348,49 +348,62 @@ export type UmeTokenStatus = { error?: string; }; -export type TopologyMapItem = { +export type TopologyViewItem = { id: string; name: string; remark: string; + filter?: Record; + viewport?: Record; node_count: number; - edge_count: number; created_at?: string | null; updated_at?: string | null; }; -export type TopologyNodeItem = { - id: string; - map_id: string; +export type TopologyViewNodeItem = { + fabric_node_id: string; managed_ne_id: string; ume_ne_id: string; label: string; x: number; y: number; - ne_name: string; - ne_ip: string; + locked: boolean; + name: string; + ip: string; vendor: string; - protocol: string; + device_type: string; connect_status: string; }; -export type TopologyEdgeItem = { +export type TopologyViewEdgeItem = { id: string; - map_id: string; - source_node_id: string; - target_node_id: string; - source_port: string; - target_port: string; + a_node_id: string; + b_node_id: string; + a_port: string; + b_port: string; source: string; + status: string; + layer: string; stroke_color?: string; stroke_width?: number; line_style?: string; discovered_at?: string | null; }; -export type TopologyGraph = { - map: TopologyMapItem; - nodes: TopologyNodeItem[]; - edges: TopologyEdgeItem[]; +export type TopologyViewGraph = { + view: TopologyViewItem; + nodes: TopologyViewNodeItem[]; + edges: TopologyViewEdgeItem[]; + truncated?: boolean; + truncate_reason?: string; +}; + +export type FabricSummary = { + node_count: number; + edge_count: number; + edge_active: number; + edge_stale: number; + last_discover_at?: string | null; + updated_at?: string | null; }; export type TopologyDiscoverUnmatched = { @@ -400,19 +413,12 @@ export type TopologyDiscoverUnmatched = { remote_port: string; }; -export type TopologyDiscoverLink = { - peer_node_id?: string; - peer_ne_id?: string; - peer_name?: string; - peer_ip?: string; - local_port?: string; - remote_port?: string; - protocol?: string; - action?: string; -}; - -export type TopologyDiscoverNeResult = { +export type TopologyDiscoverJobItem = { + id: string; + job_id: string; ne_id: string; + ume_ne_id: string; + fabric_node_id: string; ne_name: string; ne_ip: string; ok: boolean; @@ -420,44 +426,53 @@ export type TopologyDiscoverNeResult = { neighbors: number; edges_added: number; edges_updated: number; - unmatched_count?: number; + unmatched_count: number; unmatched?: TopologyDiscoverUnmatched[]; - links?: TopologyDiscoverLink[]; parser_key?: string; parser_stub?: boolean; error: string; raw_preview: string; }; +export type TopologyDiscoverJob = { + id: string; + scope: string; + trigger_mode?: string; + status: string; + total: number; + done: number; + edges_added: number; + edges_updated: number; + edges_stale: number; + error: string; + started_at?: string | null; + ended_at?: string | null; + created_at?: string | null; + items: TopologyDiscoverJobItem[]; +}; + +/** @deprecated alias — prefer TopologyViewItem */ +export type TopologyMapItem = TopologyViewItem; + +/** UI-compat aliases for restored TopologyPage. */ +export type TopologyNodeItem = TopologyViewNodeItem & { id?: string; map_id?: string; ne_name?: string; ne_ip?: string; protocol?: string }; +export type TopologyEdgeItem = TopologyViewEdgeItem & { + source_node_id?: string; + target_node_id?: string; + map_id?: string; +}; +export type TopologyDiscoverNeResult = TopologyDiscoverJobItem & { + links?: Array>; +}; export type TopologyDiscoverOut = { - map_id: string; - protocol: string; + map_id?: string; + protocol?: string; scanned: number; edges_added: number; edges_updated: number; edges_stale?: number; results: TopologyDiscoverNeResult[]; - graph: TopologyGraph | null; -}; - -export type TopologyDiscoverStreamHandlers = { - onStart?: (ev: { map_id: string; protocol: string; total: number }) => void; - onNeStart?: (ev: { - index: number; - total: number; - ne_id: string; - ne_name: string; - ne_ip: string; - }) => void; - onNeResult?: (ev: { - index: number; - total: number; - result: TopologyDiscoverNeResult; - edges_added: number; - edges_updated: number; - }) => void; - onDone?: (report: TopologyDiscoverOut) => void; - onError?: (detail: string) => void; + graph: TopologyViewGraph | null; }; export type ConfigSyncTargetRef = { @@ -465,6 +480,44 @@ export type ConfigSyncTargetRef = { id: string; }; +export type LldpCollectPolicy = { + enabled: boolean; + interval_days: number; + concurrency: number; + scope_mode: string; + selected_targets: ConfigSyncTargetRef[]; + auto_add_unmatched: boolean; + updated_at?: string | null; +}; + +export type LldpCollectJobSummary = { + id: string; + scope: string; + trigger_mode: string; + status: string; + total: number; + done: number; + edges_added: number; + edges_updated: number; + edges_stale: number; + error: string; + started_at?: string | null; + ended_at?: string | null; + created_at?: string | null; +}; + +export type LldpCollectDashboard = { + policy: LldpCollectPolicy; + fabric_node_count: number; + fabric_edge_count: number; + fabric_edge_active: number; + fabric_edge_stale: number; + last_discover_at?: string | null; + running_job: LldpCollectJobSummary | null; + last_job: LldpCollectJobSummary | null; + next_due_at?: string | null; +}; + export type ConfigSyncPolicy = { enabled: boolean; interval_days: number;