mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
9674de221d
commit
d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions
|
|
@ -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>`。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
57
netx_api/lldp_collect_router.py
Normal file
57
netx_api/lldp_collect_router.py
Normal 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)
|
||||
96
netx_api/lldp_collect_scheduler.py
Normal file
96
netx_api/lldp_collect_scheduler.py
Normal 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()
|
||||
65
netx_api/lldp_collect_schemas.py
Normal file
65
netx_api/lldp_collect_schemas.py
Normal 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)
|
||||
234
netx_api/lldp_collect_service.py
Normal file
234
netx_api/lldp_collect_service.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
64
netx_api/topology_migrate.py
Normal file
64
netx_api/topology_migrate.py
Normal 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])
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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`
|
||||
|
||||
## 兼容
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
65
tests/test_lldp_collect.py
Normal file
65
tests/test_lldp_collect.py
Normal 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
19
web/WEB.md
19
web/WEB.md
|
|
@ -123,19 +123,14 @@ src/
|
|||
- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看
|
||||
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
|
||||
|
||||
## 拓扑管理
|
||||
## 拓扑管理(Fabric + View)
|
||||
|
||||
- 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形
|
||||
- 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C`
|
||||
- 侧栏网元可点击或拖放到画布;发现默认保留节点坐标(「显示 → 发现后自动布局」可选开启并本机记住)
|
||||
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
|
||||
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
|
||||
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名
|
||||
- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化
|
||||
- 发现进度主区只显示摘要与入口;点「详情」打开列表弹窗,再点设备打开链路复核弹窗
|
||||
- 工具栏可搜索定位并高亮;侧栏已上图网元点击可定位
|
||||
- 连线模式画布顶提示;侧栏折叠/编辑/删除使用内联 SVG 图标;名称与 IP/端口间隔统一为 ASCII ` / `(避免 Unicode 损坏)
|
||||
- 链路右键可跳转端口流量(`/network/tasks/port-traffic?ne_id=&source=&ifname=`)
|
||||
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
||||
- 视图:`topo_view` + `topo_view_node`(坐标);旧 `topology_map/node/edge` 已退役
|
||||
- API:`/v1/topology/fabric/*`(summary / nodes / edges / neighborhood / discover job)、`/v1/topology/views*`
|
||||
- 前端:`@xyflow/react`;侧栏上图、拖拽保存坐标(PATCH positions)、布局(dagre/力导向/网格/环形)、轮询 LLDP job
|
||||
- MCP:`getTopologySummary`、`queryTopologyNodes/Edges`、`getTopologyNeighborhood`、`runLldpDiscover`、`getLldpDiscoverJob`、`listTopologyViews`、`getTopologyView`
|
||||
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
|
||||
|
||||
## WebCRT
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
581
web/src/pages/network/LldpLinksPage.tsx
Normal file
581
web/src/pages/network/LldpLinksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
163
web/src/types.ts
163
web/src/types.ts
|
|
@ -348,49 +348,62 @@ export type UmeTokenStatus = {
|
|||
error?: string;
|
||||
};
|
||||
|
||||
export type TopologyMapItem = {
|
||||
export type TopologyViewItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
filter?: Record<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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue