netx/netx_api/topology_router.py
oliver e7f97b7cf6 Harden LLDP collect ops: edge list, job reclaim, and schedule hygiene.
Add fabric link browsing, conservative job retention, hour-based intervals, multi-worker start locking, and trim canvas discover UI in favor of the LLDP page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 03:58:03 +08:00

205 lines
5.7 KiB
Python

"""Topology HTTP routes — fabric + views (final model)."""
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 .topology_schemas import (
FabricDiscoverRequest,
FabricManualEdgeIn,
TopologyViewCreate,
TopologyViewUpdate,
ViewEdgeStylePatch,
ViewNodesAdd,
ViewPositionsPatch,
)
from .topology_service import (
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"])
# --- Fabric -----------------------------------------------------------------
@router.get("/fabric/summary")
def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]:
return get_fabric_summary(db).model_dump()
@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]:
return list_fabric_nodes(db, keyword=keyword, page=page, page_size=page_size)
@router.get("/fabric/edges")
def api_fabric_edges(
node_id: str = "",
layer: str = "physical",
status: str = "",
source: str = "",
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]:
return list_fabric_edges(
db,
node_id=node_id,
layer=layer,
status=status,
source=source,
keyword=keyword,
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()