mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Toolbar and pane context menu create placeholders that follow the same setup path as LLDP in NE management and WebCRT. Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
8.5 KiB
Python
243 lines
8.5 KiB
Python
"""Discover job serializers, scan-target resolution, and history prune."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .cli_resolve import get_default_profile, infer_device_type_vendor
|
|
from .device_types import is_placeholder_ne_source
|
|
from .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE
|
|
from .topology_common import _RAW_PREVIEW_MAX
|
|
from .topology_schemas import (
|
|
FabricDiscoverJobItemOut,
|
|
FabricDiscoverJobOut,
|
|
FabricDiscoverRequest,
|
|
FabricDiscoverUnmatched,
|
|
)
|
|
|
|
|
|
def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str:
|
|
text = str(raw or "")
|
|
if len(text) <= limit:
|
|
return text
|
|
return f"{text[:limit]}\n...[truncated preview {limit}/{len(text)} chars]"
|
|
|
|
|
|
def _is_lldp_placeholder_ne(ne: ManagedNE) -> bool:
|
|
"""Incomplete placeholders (LLDP / topology) must not be used as discover targets."""
|
|
return is_placeholder_ne_source(ne.source)
|
|
|
|
|
|
def _managed_target_dict(ne: ManagedNE) -> dict[str, str]:
|
|
return {
|
|
"ne_id": ne.id,
|
|
"ume_ne_id": "",
|
|
"ne_name": ne.name or "",
|
|
"ne_ip": ne.ip_address or "",
|
|
"vendor": ne.vendor or "",
|
|
"device_type": ne.device_type or "",
|
|
}
|
|
|
|
|
|
def _job_out(
|
|
db: Session,
|
|
job: TopoDiscoverJob,
|
|
*,
|
|
include_items: bool = True,
|
|
page: int | None = None,
|
|
page_size: int | None = None,
|
|
) -> FabricDiscoverJobOut:
|
|
items_out: list[FabricDiscoverJobItemOut] = []
|
|
items_total = 0
|
|
items_page = 1
|
|
items_page_size = 0
|
|
if include_items:
|
|
q = (
|
|
db.query(TopoDiscoverJobItem)
|
|
.filter(TopoDiscoverJobItem.job_id == job.id)
|
|
.order_by(TopoDiscoverJobItem.created_at.asc())
|
|
)
|
|
items_total = int(q.count())
|
|
if page is not None and page_size is not None:
|
|
items_page = max(1, int(page or 1))
|
|
items_page_size = max(1, min(100, int(page_size or 20)))
|
|
items = q.offset((items_page - 1) * items_page_size).limit(items_page_size).all()
|
|
else:
|
|
items = q.all()
|
|
items_page = 1
|
|
items_page_size = items_total
|
|
for it in items:
|
|
unmatched = [
|
|
FabricDiscoverUnmatched.model_validate(x) for x in (it.unmatched_json or [])[:40]
|
|
]
|
|
items_out.append(
|
|
FabricDiscoverJobItemOut(
|
|
id=it.id,
|
|
job_id=it.job_id,
|
|
ne_id=it.ne_id or "",
|
|
ume_ne_id=it.ume_ne_id or "",
|
|
fabric_node_id=it.fabric_node_id or "",
|
|
ne_name=it.ne_name or "",
|
|
ne_ip=it.ne_ip or "",
|
|
ok=bool(it.ok),
|
|
command=it.command or "",
|
|
neighbors=int(it.neighbors or 0),
|
|
edges_added=int(it.edges_added or 0),
|
|
edges_updated=int(it.edges_updated or 0),
|
|
unmatched_count=int(it.unmatched_count or 0),
|
|
unmatched=unmatched,
|
|
parser_key=it.parser_key or "",
|
|
parser_stub=bool(it.parser_stub),
|
|
error=it.error or "",
|
|
raw_preview=it.raw_preview or "",
|
|
)
|
|
)
|
|
return FabricDiscoverJobOut(
|
|
id=job.id,
|
|
scope=job.scope,
|
|
trigger_mode=str(getattr(job, "trigger_mode", None) or "manual"),
|
|
status=job.status,
|
|
total=int(job.total or 0),
|
|
done=int(job.done or 0),
|
|
edges_added=int(job.edges_added or 0),
|
|
edges_updated=int(job.edges_updated or 0),
|
|
edges_stale=int(job.edges_stale or 0),
|
|
edges_missing=int(job.edges_stale or 0),
|
|
error=job.error or "",
|
|
started_at=job.started_at,
|
|
ended_at=job.ended_at,
|
|
items=items_out,
|
|
items_total=items_total,
|
|
items_page=items_page,
|
|
items_page_size=items_page_size,
|
|
)
|
|
|
|
|
|
def get_discover_job(
|
|
db: Session,
|
|
job_id: str,
|
|
*,
|
|
page: int | None = None,
|
|
page_size: int | None = None,
|
|
) -> FabricDiscoverJobOut:
|
|
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="discover_job_not_found")
|
|
return _job_out(db, job, page=page, page_size=page_size)
|
|
|
|
|
|
def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None:
|
|
ume = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == uid).one_or_none()
|
|
if ume is None:
|
|
return None
|
|
if default_profile is not None:
|
|
dtype, vendor = infer_device_type_vendor(str(ume.ne_type or ""), default_profile)
|
|
else:
|
|
dtype, vendor = "zte_zxros", (ume.vendor or "ZTE")
|
|
name = (ume.host_name or ume.ne_name or ume.user_label or ume.ip_address or uid).strip()
|
|
return {
|
|
"ne_id": uid,
|
|
"ume_ne_id": uid,
|
|
"ne_name": name,
|
|
"ne_ip": ume.ip_address or "",
|
|
"vendor": vendor or (ume.vendor or "ZTE"),
|
|
"device_type": dtype or "zte_zxros",
|
|
}
|
|
|
|
|
|
def _resolve_scan_targets(
|
|
db: Session, body: FabricDiscoverRequest
|
|
) -> list[dict[str, str]]:
|
|
scope = str(body.scope or "ne_ids").strip().lower() or "ne_ids"
|
|
default_profile = get_default_profile(db)
|
|
targets: list[dict[str, str]] = []
|
|
if scope == "all_inventory":
|
|
# Managed inventory first; then UME NEs not already covered by the same management IP.
|
|
# Skip LLDP placeholders (no login yet); once promoted they join normal inventory.
|
|
managed_ips: set[str] = set()
|
|
for ne in db.query(ManagedNE).all():
|
|
if _is_lldp_placeholder_ne(ne):
|
|
continue
|
|
ip = str(ne.ip_address or "").strip()
|
|
if ip:
|
|
managed_ips.add(ip)
|
|
targets.append(_managed_target_dict(ne))
|
|
for ume in db.query(UmeInventoryNE).all():
|
|
uid = str(ume.ne_id or "").strip()
|
|
if not uid:
|
|
continue
|
|
ip = str(ume.ip_address or "").strip()
|
|
if ip and ip in managed_ips:
|
|
continue
|
|
row = _ume_target_dict(db, uid, default_profile)
|
|
if row is not None:
|
|
targets.append(row)
|
|
return targets
|
|
|
|
managed_ids = [str(x).strip() for x in (body.managed_ne_ids or []) if str(x).strip()]
|
|
ume_ids = [str(x).strip() for x in (body.ume_ne_ids or []) if str(x).strip()]
|
|
if managed_ids or ume_ids:
|
|
seen: set[str] = set()
|
|
for mid in managed_ids:
|
|
if mid in seen:
|
|
continue
|
|
ne = db.get(ManagedNE, mid)
|
|
if ne is None or _is_lldp_placeholder_ne(ne):
|
|
continue
|
|
seen.add(mid)
|
|
targets.append(_managed_target_dict(ne))
|
|
for uid in ume_ids:
|
|
key = f"ume:{uid}"
|
|
if key in seen:
|
|
continue
|
|
row = _ume_target_dict(db, uid, default_profile)
|
|
if row is None:
|
|
continue
|
|
seen.add(key)
|
|
targets.append(row)
|
|
if not targets:
|
|
raise HTTPException(status_code=400, detail="ne_ids_required")
|
|
return targets
|
|
|
|
# Legacy mixed ne_ids: prefer ManagedNE, leftover treated as UME.
|
|
filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()}
|
|
if not filter_ids:
|
|
raise HTTPException(status_code=400, detail="ne_ids_required")
|
|
for mid in list(filter_ids):
|
|
ne = db.get(ManagedNE, mid)
|
|
if ne is not None:
|
|
filter_ids.discard(mid)
|
|
if _is_lldp_placeholder_ne(ne):
|
|
continue
|
|
targets.append(_managed_target_dict(ne))
|
|
for uid in list(filter_ids):
|
|
row = _ume_target_dict(db, uid, default_profile)
|
|
if row is not None:
|
|
targets.append(row)
|
|
return targets
|
|
|
|
|
|
def prune_discover_jobs(db: Session, *, keep: int = 30) -> int:
|
|
"""Delete finished discover jobs beyond ``keep`` (newest kept). Open jobs always retained."""
|
|
keep = max(0, min(200, int(keep)))
|
|
finished = (
|
|
db.query(TopoDiscoverJob)
|
|
.filter(TopoDiscoverJob.status.in_(["done", "failed", "cancelled"]))
|
|
.order_by(TopoDiscoverJob.created_at.desc())
|
|
.all()
|
|
)
|
|
to_drop = finished if keep == 0 else finished[keep:]
|
|
if not to_drop:
|
|
return 0
|
|
dropped = 0
|
|
for job in to_drop:
|
|
db.query(TopoDiscoverJobItem).filter(TopoDiscoverJobItem.job_id == job.id).delete(
|
|
synchronize_session=False
|
|
)
|
|
db.delete(job)
|
|
dropped += 1
|
|
if dropped:
|
|
db.commit()
|
|
return dropped
|