Add LLDP collect page with conservative fabric edge lifecycle.

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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 03:39:15 +08:00
parent 9674de221d
commit d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions

View file

@ -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__<toolName>`。
物理拓扑仅 LLDP;分页查询,勿默认拉全图。oclaw 中名称带前缀:`mcp__netx__<toolName>`。
---

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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`
## 兼容

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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() {
<Route path="devices" element={<NetworkDevicesPage />} />
<Route path="alarms" element={<NetworkAlarmsPage />} />
<Route path="configs" element={<NetworkConfigsPage />} />
<Route path="topology" element={<Navigate to="/topology" replace />} />
<Route path="topology/lldp" element={<LldpLinksPage />} />
<Route path="topology" element={<Navigate to="/network/topology/lldp" replace />} />
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
<Route path="tasks/collect" element={<CollectPage />} />
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NeNodeData>[] = 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<NeNodeData>[], edges: Edge[]) {
return {
nodes: nodes.map((n) => ({
id: n.id,
managed_ne_id: n.data.managed_ne_id || "",
ume_ne_id: n.data.ume_ne_id || "",
label: n.data.label || "",
function flowToPositions(nodes: Node<NeNodeData>[]) {
return nodes.map((n) => ({
fabric_node_id: n.id,
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,
};
}),
};
label: n.data.label || "",
}));
}
function applyViewGraph(
graph: TopologyViewGraph,
defaults: EdgeDefaults,
setNodes: (ns: Node<NeNodeData>[]) => 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(
const scoped = scannable.filter(
(n) =>
filterIds.includes(String(n.data.managed_ne_id || "")) ||
filterIds.includes(String(n.data.ume_ne_id || "")),
)
: scannable;
);
setDiscoverProgress({
index: 0,
total: scoped.length,
@ -847,52 +852,48 @@ 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]);
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: ev.index,
total: ev.total,
edgesAdded: ev.edges_added,
edgesUpdated: ev.edges_updated,
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,
}));
},
},
ac.signal,
);
if (out.graph) {
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults);
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 putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes));
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
clearDirty();
} catch {
@ -905,13 +906,22 @@ export function TopologyPage() {
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 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,
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));
}
})();
},
edgeDefaults,
),
eds,
),
);
},
[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;
}
try {
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,
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, [
{
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,
},
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 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,
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, [
{
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 || "",
},
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();
} catch (err) {
showError(String(err));
}
},
[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();
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();
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]);
} 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<NeNodeData> | null) => {
closeCtxMenu();
const managedId = node?.data.managed_ne_id;
@ -1894,23 +1894,6 @@ export function TopologyPage() {
? t("topology.saveDirty")
: t("topology.save")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={!mapId || discovering}
onClick={() => void runDiscover()}
>
{discovering ? t("topology.discovering") : t("topology.discover")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!mapId || discovering || staleEdgeCount <= 0}
onClick={removeStaleEdges}
title={t("topology.removeStaleHint")}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
@ -2191,7 +2174,6 @@ export function TopologyPage() {
className="btn btn--sm btn--ghost"
onClick={() => {
if (discovering) {
discoverAbortRef.current?.abort();
return;
}
setDiscoverOpen(false);
@ -2391,28 +2373,6 @@ export function TopologyPage() {
<li className="topo-ctx__head" role="presentation">
{t("topology.selectionMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={discovering}
onClick={() => {
const ids = selectedNodes
.map((n) => String(n.data.managed_ne_id || n.data.ume_ne_id || "").trim())
.filter(Boolean);
closeCtxMenu();
if (!ids.length) {
showError(t("topology.discoverOneNeedNe"));
return;
}
void runDiscover(ids);
}}
>
{t("topology.discoverSelected").replace("{{count}}", String(selectedNodes.length))}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"

View file

@ -0,0 +1,581 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchCliTargets,
fetchLldpCollectDashboard,
fetchLldpCollectJob,
fetchLldpCollectJobs,
startLldpCollect,
updateLldpCollectPolicy,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 20;
const SEP = " · ";
export function LldpLinksPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [jobPage, setJobPage] = useState(1);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
const [enabled, setEnabled] = useState(false);
const [intervalDays, setIntervalDays] = useState(1);
const [concurrency, setConcurrency] = useState(4);
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
const [autoAdd, setAutoAdd] = useState(true);
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
const [policyHydrated, setPolicyHydrated] = useState(false);
const [targetKeyword, setTargetKeyword] = useState("");
const [targetPage, setTargetPage] = useState(1);
const dashQuery = useQuery({
queryKey: queryKeys.lldpCollectDashboard,
queryFn: fetchLldpCollectDashboard,
staleTime: 1000,
refetchInterval: (q) => {
const running = q.state.data?.running_job;
return running && (running.status === "running" || running.status === "pending") ? POLL_MS : false;
},
});
useEffect(() => {
if (!dashQuery.data || policyHydrated) return;
const p = dashQuery.data.policy;
setEnabled(Boolean(p.enabled));
setIntervalDays(Number(p.interval_days || 1));
setConcurrency(Number(p.concurrency || 4));
setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
setAutoAdd(Boolean(p.auto_add_unmatched));
const map: Record<string, ConfigSyncTargetRef> = {};
for (const ref of p.selected_targets || []) {
const source = ref.source === "ume" ? "ume" : "managed";
map[`${source}:${ref.id}`] = { source, id: ref.id };
}
setSelectedMap(map);
setPolicyHydrated(true);
}, [dashQuery.data, policyHydrated]);
const jobsQuery = useQuery({
queryKey: queryKeys.lldpCollectJobs(jobPage),
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
});
const jobDetailQuery = useQuery({
queryKey: queryKeys.lldpCollectJob(expandedJobId),
queryFn: () => fetchLldpCollectJob(expandedJobId),
enabled: Boolean(expandedJobId),
staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
});
const targetsQuery = useQuery({
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
queryFn: () =>
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
enabled: scopeMode === "selected",
staleTime: 5000,
});
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard }),
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobsAll }),
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobAll }),
]);
};
const savePolicyMut = useMutation({
mutationFn: () =>
updateLldpCollectPolicy({
enabled,
interval_days: intervalDays,
concurrency,
scope_mode: scopeMode,
auto_add_unmatched: autoAdd,
selected_targets: Object.values(selectedMap),
}),
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 || 1));
setConcurrency(Number(saved.concurrency || 4));
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
setAutoAdd(Boolean(saved.auto_add_unmatched));
const map: Record<string, ConfigSyncTargetRef> = {};
for (const ref of saved.selected_targets || []) {
const source = ref.source === "ume" ? "ume" : "managed";
map[`${source}:${ref.id}`] = { source, id: ref.id };
}
setSelectedMap(map);
setPolicyHydrated(true);
queryClient.setQueryData(queryKeys.lldpCollectDashboard, (prev: unknown) => {
if (!prev || typeof prev !== "object") return prev;
return { ...(prev as object), policy: saved };
});
showOk(t("lldpLinks.policySaved"));
await refresh();
},
onError: (err) => showError(String(err)),
});
const startMut = useMutation({
mutationFn: () => startLldpCollect(),
onSuccess: async () => {
showOk(t("lldpLinks.started"));
await refresh();
},
onError: (err) => showError(String(err)),
});
const dash = dashQuery.data;
const running = dash?.running_job;
const last = dash?.last_job;
const jobs = jobsQuery.data?.items ?? [];
const jobTotal = Number(jobsQuery.data?.total || 0);
const jobPages = pageCount(jobTotal, 10);
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
const detailItems = jobDetailQuery.data?.items ?? [];
const toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed";
const key = `${source}:${row.id}`;
setSelectedMap((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { source, id: row.id };
return next;
});
};
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("lldpLinks.title")}</h2>
<div className="btn-row">
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
{t("common.refresh")}
</button>
<button
type="button"
className="btn-primary"
disabled={Boolean(running) || startMut.isPending}
onClick={() => startMut.mutate()}
>
{t("lldpLinks.collectNow")}
</button>
</div>
</div>
<div className="pt-list" style={{ marginBottom: 16 }}>
<div className="pt-list-kpis">
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.nodes")}</div>
<div className="pt-list-kpi__value">{dash?.fabric_node_count ?? "—"}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.edges")}</div>
<div className="pt-list-kpi__value">{dash?.fabric_edge_active ?? "—"}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.missing")}</div>
<div className="pt-list-kpi__value">{dash?.fabric_edge_stale ?? "—"}</div>
</div>
<div className={`pt-list-kpi${running ? " pt-list-kpi--live" : ""}`}>
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.running")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: running ? 15 : 22 }}>
{running
? `${running.status} · ${running.done}/${running.total}`
: t("lldpLinks.kpi.idle")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.last")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
{last
? `${last.status} · +${last.edges_added} / ~${last.edges_updated}`
: t("common.empty")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.nextDue")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
</div>
</div>
</div>
</div>
<div className="panel" style={{ marginBottom: 16 }}>
<h3>{t("lldpLinks.policyTitle")}</h3>
<div className="config-sync-policy-row">
<label className="config-sync-policy-check">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>{t("lldpLinks.enabled")}</span>
</label>
<label className="config-sync-policy-check">
<input type="checkbox" checked={autoAdd} onChange={(e) => setAutoAdd(e.target.checked)} />
<span>{t("lldpLinks.autoAddUnmatched")}</span>
</label>
<label className="config-sync-policy-field">
<span>{t("lldpLinks.intervalDays")}</span>
<input
type="number"
min={1}
max={365}
value={intervalDays}
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("lldpLinks.concurrency")}</span>
<input
type="number"
min={1}
max={32}
value={concurrency}
onChange={(e) => setConcurrency(Math.max(1, Math.min(32, Number(e.target.value) || 1)))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("lldpLinks.scope")}</span>
<select
value={scopeMode}
onChange={(e) => setScopeMode(e.target.value === "selected" ? "selected" : "all")}
>
<option value="all">{t("lldpLinks.scopeAll")}</option>
<option value="selected">{t("lldpLinks.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMut.isPending}
onClick={() => savePolicyMut.mutate()}
>
{t("lldpLinks.savePolicy")}
</button>
</div>
{scopeMode === "selected" ? (
<div style={{ marginTop: 12 }}>
<p className="muted">{t("lldpLinks.selectedCount", { count: String(selectedCount) })}</p>
<div className="filter-inline">
<input
value={targetKeyword}
placeholder={t("lldpLinks.targetKeywordPh")}
onChange={(e) => {
setTargetKeyword(e.target.value);
setTargetPage(1);
}}
/>
</div>
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("lldpLinks.col.source")}</th>
<th>{t("lldpLinks.col.name")}</th>
<th>IP</th>
<th>{t("lldpLinks.col.vendor")}</th>
</tr>
</thead>
<tbody>
{(targetsQuery.data?.items ?? []).map((row) => {
const source = row.source === "ume" ? "ume" : "managed";
const key = `${source}:${row.id}`;
return (
<tr key={key}>
<td>
<input
type="checkbox"
checked={Boolean(selectedMap[key])}
onChange={() => toggleTarget(row)}
/>
</td>
<td>{source}</td>
<td>{row.name}</td>
<td>{row.ip_address}</td>
<td>{row.vendor || "-"}</td>
</tr>
);
})}
</tbody>
</table>
<div className="pager">
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
page: String(targetPage),
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
</span>
<button
type="button"
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onClick={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}
</div>
<h3>{t("lldpLinks.jobsTitle")}</h3>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("lldpLinks.col.trigger")}</th>
<th>{t("lldpLinks.col.scope")}</th>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.progress")}</th>
<th>{t("lldpLinks.col.edges")}</th>
<th>{t("lldpLinks.col.missingDelta")}</th>
<th>{t("lldpLinks.col.started")}</th>
<th>{t("lldpLinks.col.ended")}</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => {
const open = expandedJobId === job.id;
return (
<tr key={job.id} className={open ? "is-expanded" : undefined}>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setItemDetail(null);
setExpandedJobId(open ? "" : job.id);
}}
>
{open ? "−" : "+"}
</button>
</td>
<td className="mono">{job.id.slice(0, 8)}</td>
<td>{job.trigger_mode}</td>
<td>{job.scope}</td>
<td>{job.status}</td>
<td>
{job.done}/{job.total}
</td>
<td>
+{job.edges_added} / ~{job.edges_updated}
</td>
<td>{job.edges_stale || 0}</td>
<td>{job.started_at ? formatSystemTime(job.started_at) : "—"}</td>
<td>{job.ended_at ? formatSystemTime(job.ended_at) : "—"}</td>
</tr>
);
})}
{!jobs.length ? (
<tr>
<td colSpan={10} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="pager">
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(jobTotal),
page: String(jobPage),
pages: String(jobPages),
})}
</span>
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
{expandedJobId ? (
<div className="panel" style={{ marginTop: 16 }}>
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
{jobDetailQuery.isLoading ? <p className="muted">…</p> : null}
{jobDetailQuery.data?.error ? (
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
{jobDetailQuery.data.error}
</p>
) : null}
<table className="data-table">
<thead>
<tr>
<th>{t("lldpLinks.col.name")}</th>
<th>IP</th>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.neighbors")}</th>
<th>{t("lldpLinks.col.edges")}</th>
<th>{t("lldpLinks.col.error")}</th>
<th />
</tr>
</thead>
<tbody>
{detailItems.map((it) => {
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
return (
<tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td>
<td>
{!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"}
</td>
<td>{it.neighbors}</td>
<td>
+{it.edges_added} / ~{it.edges_updated}
{unmatchedCount > 0
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(it)}
>
{t("topology.discoverViewDetail")}
</button>
</td>
</tr>
);
})}
{!detailItems.length && !jobDetailQuery.isLoading ? (
<tr>
<td colSpan={7} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
{itemDetail ? (
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
<div
className="modal modal--wide topo-discover-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lldp-item-detail-title"
onClick={(e) => e.stopPropagation()}
>
<div className="topo-discover-modal__head">
<div>
<h3 id="lldp-item-detail-title">
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
</h3>
<p className="topo-discover-modal__sub">
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
</p>
</div>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(null)}
>
{t("topology.discoverClose")}
</button>
</div>
{!itemDetail.ok ? (
<p className="topo-discover__error">
{itemDetail.error || t("topology.discoverNeFail")}
</p>
) : null}
{itemDetail.parser_stub ? (
<p className="topo-discover__item-warn">
{t("topology.discoverParserStub").replace(
"{{parser}}",
itemDetail.parser_key || "unknown",
)}
</p>
) : null}
<div className="topo-discover-modal__stats">
<span>
{t("topology.discoverNeOk")
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
.replace("{{added}}", String(itemDetail.edges_added || 0))
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
</span>
</div>
<h4 className="topo-discover-modal__section">
{t("topology.discoverUnmatchedTitle").replace(
"{{count}}",
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
)}
</h4>
{(itemDetail.unmatched || []).length === 0 ? (
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColRemote")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
</tr>
</thead>
<tbody>
{(itemDetail.unmatched || []).map((u, idx) => (
<tr key={`${itemDetail.id}-u-${idx}`}>
<td>
{(u.remote_name || u.remote_ip || "?").trim()}
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
</td>
<td>{u.local_port || "?"}</td>
<td>{u.remote_port || "?"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{itemDetail.raw_preview ? (
<>
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
</>
) : null}
<div className="modal__actions">
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</button>
</div>
</div>
</div>
) : null}
</section>
);
}

View file

@ -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<TopologyMapItem>("/v1/topology/maps", body);
export const createTopologyView = (body: { name: string; remark?: string; filter?: Record<string, unknown> }) =>
apiPost<TopologyViewItem>("/v1/topology/views", body);
export const fetchTopologyGraph = (mapId: string) =>
apiGet<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}`);
export const fetchTopologyViewGraph = (viewId: string) =>
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`);
export const updateTopologyMap = (mapId: string, body: { name?: string; remark?: string }) =>
apiPatch<TopologyMapItem>(`/v1/topology/maps/${encodeURIComponent(mapId)}`, body);
export const updateTopologyView = (
viewId: string,
body: { name?: string; remark?: string; filter?: Record<string, unknown>; viewport?: Record<string, unknown> },
) => apiPatch<TopologyViewItem>(`/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;
export const patchTopologyPositions = (
viewId: string,
positions: Array<{
fabric_node_id: 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<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);
export const discoverTopologyNeighbors = (
mapId: string,
body?: { protocol?: string; ne_ids?: string[] },
label?: string;
locked?: boolean;
}>,
) =>
apiPost<TopologyDiscoverOut>(
`/v1/topology/maps/${encodeURIComponent(mapId)}/discover`,
body || {},
apiPatch<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, {
positions,
});
export const addTopologyViewNodes = (
viewId: string,
body: {
managed_ne_ids?: string[];
ume_ne_ids?: string[];
fabric_node_ids?: string[];
layout?: string;
},
) => apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body);
export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) =>
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, {
fabric_node_ids: fabricNodeIds,
});
export const projectTopologyNeighbors = (viewId: string) =>
apiPost<TopologyViewGraph>(`/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<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body);
export const fetchFabricSummary = () => apiGet<FabricSummary>("/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<TopologyDiscoverJob>("/v1/topology/fabric/discover", body || {});
export const fetchLldpDiscoverJob = (jobId: string) =>
apiGet<TopologyDiscoverJob>(`/v1/topology/fabric/discover/${encodeURIComponent(jobId)}`);
export const fetchLldpCollectDashboard = () =>
apiGet<LldpCollectDashboard>("/v1/topology/lldp-collect/dashboard");
export const updateLldpCollectPolicy = (body: Partial<LldpCollectPolicy>) =>
apiPut<LldpCollectPolicy>("/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()}`,
);
};
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 const fetchLldpCollectJob = (jobId: string) =>
apiGet<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`);
export async function discoverTopologyNeighborsStream(
mapId: string,
body: { protocol?: string; ne_ids?: string[] } | undefined,
handlers: TopologyDiscoverStreamHandlers,
signal?: AbortSignal,
): Promise<TopologyDiscoverOut> {
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,
});
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");
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<string, unknown> }>("/v1/topology/fabric/edges", body);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let finalReport: TopologyDiscoverOut | null = null;
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<string, unknown> = {};
try {
payload = JSON.parse(item.data) as Record<string, unknown>;
} 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);
}
}
}
if (!finalReport) throw new Error("discover_stream_incomplete");
return finalReport;
}
/** 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<ConfigSyncDashboard>("/v1/config-sync/dashboard");

View file

@ -348,49 +348,62 @@ export type UmeTokenStatus = {
error?: string;
};
export type TopologyMapItem = {
export type TopologyViewItem = {
id: string;
name: string;
remark: string;
filter?: Record<string, unknown>;
viewport?: Record<string, unknown>;
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<Record<string, unknown>>;
};
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;