mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10: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
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue